mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-27 01:52:25 +02:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0818530f07 | ||
|
|
0eac6626b3 | ||
|
|
67b2512857 | ||
|
|
2982512adb | ||
|
|
4956512692 | ||
|
|
40055e92cc | ||
|
|
f4a5d56535 | ||
|
|
742488d5a0 | ||
|
|
b319426725 | ||
|
|
bebcd20815 | ||
|
|
2ad30132a3 | ||
|
|
7715239e3b | ||
|
|
ad0146a4de | ||
|
|
b6a196fac6 | ||
|
|
910240a930 | ||
|
|
ab1d6980b5 | ||
|
|
7e5df56688 | ||
|
|
0f2f66fc8f | ||
|
|
acaa767542 | ||
|
|
5f24a0356a | ||
|
|
adda5f0341 | ||
|
|
6fcca359df | ||
|
|
28ce32edc7 | ||
|
|
119847f80f | ||
|
|
f43743c3e7 | ||
|
|
dbded7a59e | ||
|
|
986c1cc2e3 | ||
|
|
28cf5ed78d | ||
|
|
ca5d9a6bd7 | ||
|
|
ae3c823499 | ||
|
|
b72b5fefd0 | ||
|
|
5b1f86facc | ||
|
|
cf966419d5 | ||
|
|
8f03bf9f15 | ||
|
|
0076da9008 | ||
|
|
7fa49bd550 | ||
|
|
6719d34023 | ||
|
|
96843788d0 | ||
|
|
ba5e3c4a9b | ||
|
|
9e9783b156 | ||
|
|
c27d103e04 | ||
|
|
d189f6551e | ||
|
|
1dd9adf833 | ||
|
|
92c66ca997 | ||
|
|
a7356edf8a | ||
|
|
354dceaac7 | ||
|
|
2ff294af77 | ||
|
|
4c67f84016 | ||
|
|
2c98c59fca | ||
|
|
d27d4b2d98 | ||
|
|
742d165acb | ||
|
|
b442bf802a | ||
|
|
bede0b7acf | ||
|
|
3a5b5c9821 |
Generated
+3
-3
@@ -623,7 +623,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -6,6 +6,11 @@ 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]
|
||||
|
||||
Generated
+3
-3
@@ -950,7 +950,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -961,9 +961,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
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
|
||||
|
||||
Generated
+6
-6
@@ -249,7 +249,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.22"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -261,9 +261,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
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" }
|
||||
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/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" },
|
||||
{ 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]]
|
||||
@@ -862,7 +862,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -873,9 +873,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -26,6 +26,9 @@ 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,3 +1,10 @@
|
||||
"""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
|
||||
|
||||
@@ -56,6 +56,10 @@ 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__(
|
||||
@@ -70,8 +74,11 @@ 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 = (
|
||||
@@ -530,7 +537,8 @@ def _create_msgpack_ext_hook(
|
||||
logger.warning(
|
||||
"Deserializing unregistered type %s.%s from checkpoint. "
|
||||
"This will be blocked in a future version. "
|
||||
"Add to allowed_msgpack_modules to silence: [(%r, %r)]",
|
||||
"Set LANGGRAPH_STRICT_MSGPACK=true to block now, or add "
|
||||
"to allowed_msgpack_modules to allow explicitly: [(%r, %r)]",
|
||||
module,
|
||||
name,
|
||||
module,
|
||||
|
||||
Generated
+3
-3
@@ -1117,7 +1117,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1128,9 +1128,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1086,11 +1086,6 @@
|
||||
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"
|
||||
@@ -1782,13 +1777,6 @@ 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"
|
||||
@@ -3688,16 +3676,12 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
|
||||
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
|
||||
version "0.5.18"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.18.tgz#c691ad23614f0b46eaf07d982e0ac988e1f43880"
|
||||
integrity sha512-3zuZUWffTHQ+73EAwnodADtf534VNEZUpXr9jC12qyG8/IQuJET7PRsCpTb9wX2lmBspakwLUpqpj3tNm/0bVA==
|
||||
dependencies:
|
||||
"@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"
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
leven@^3.1.0:
|
||||
version "3.1.0"
|
||||
@@ -4007,7 +3991,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==
|
||||
@@ -4303,7 +4287,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.6.3, semver@^7.7.2, semver@^7.7.3:
|
||||
semver@^7.5.3, semver@^7.5.4, 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==
|
||||
@@ -4411,11 +4395,6 @@ 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"
|
||||
@@ -4870,7 +4849,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==
|
||||
|
||||
@@ -217,11 +217,6 @@
|
||||
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"
|
||||
@@ -343,13 +338,6 @@ 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"
|
||||
@@ -508,38 +496,11 @@ 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"
|
||||
@@ -1059,11 +1020,6 @@ 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"
|
||||
@@ -1372,16 +1328,12 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
|
||||
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
|
||||
version "0.5.18"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.18.tgz#c691ad23614f0b46eaf07d982e0ac988e1f43880"
|
||||
integrity sha512-3zuZUWffTHQ+73EAwnodADtf534VNEZUpXr9jC12qyG8/IQuJET7PRsCpTb9wX2lmBspakwLUpqpj3tNm/0bVA==
|
||||
dependencies:
|
||||
"@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"
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
@@ -1528,7 +1480,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==
|
||||
@@ -1690,11 +1642,6 @@ 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"
|
||||
@@ -1783,11 +1730,6 @@ 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"
|
||||
@@ -1838,13 +1780,6 @@ 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"
|
||||
@@ -1966,7 +1901,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==
|
||||
|
||||
Generated
+23
-23
@@ -907,7 +907,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.27"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch", marker = "python_full_version >= '3.11'" },
|
||||
@@ -919,9 +919,9 @@ dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "uuid-utils", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/5c/56d19a252bbb26247b7a7cd20821d48804d7ca03212fec709cd8db7c2516/langchain_core-1.2.27.tar.gz", hash = "sha256:c18372e4c4c1454d49bf23a2e484431e71bd39b64173a0f621f0fc283d7183a4", size = 844935, upload-time = "2026-04-07T14:56:32.364Z" }
|
||||
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/c1/c3/6e0865bc130c448270eb9511b47863a3f9145cdb519b19f6e4758fa63d6f/langchain_core-1.2.27-py3-none-any.whl", hash = "sha256:9ecd6b0393b969fe88f6b9b309367134080ab095946d79e6937dd3911aa42bd5", size = 508315, upload-time = "2026-04-07T14:56:30.93Z" },
|
||||
{ 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]]
|
||||
@@ -2318,28 +2318,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uv"
|
||||
version = "0.11.3"
|
||||
version = "0.11.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/ed/f11c558e8d2e02fba6057dacd9e92a71557359a80bd5355452310b89f40f/uv-0.11.3.tar.gz", hash = "sha256:6a6fcaf1fec28bbbdf0dfc5a0a6e34be4cea08c6287334b08c24cf187300f20d", size = 4027684, upload-time = "2026-04-01T21:47:22.096Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/93/4f04c49fd6046a18293de341d795ded3b9cbd95db261d687e26db0f11d1e/uv-0.11.3-py3-none-linux_armv6l.whl", hash = "sha256:deb533e780e8181e0859c68c84f546620072cd1bd827b38058cb86ebfba9bb7d", size = 23337334, upload-time = "2026-04-01T21:46:47.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4b/c44fd3fbc80ac2f81e2ad025d235c820aac95b228076da85be3f5d509781/uv-0.11.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d2b3b0fa1693880ca354755c216ae1c65dd938a4f1a24374d0c3f4b9538e0ee6", size = 22940169, upload-time = "2026-04-01T21:47:32.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c7/7d01be259a47d42fa9e80adcb7a829d81e7c376aa8fa1b714f31d7dfc226/uv-0.11.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71f5d0b9e73daa5d8a7e2db3fa2e22a4537d24bb4fe78130db797280280d4edc", size = 21473579, upload-time = "2026-04-01T21:47:25.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/71/fffcd890290a4639a3799cf3f3e87947c10d1b0de19eba3cf837cb418dd8/uv-0.11.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:55ba578752f29a3f2b22879b22a162edad1454e3216f3ca4694fdbd4093a6822", size = 23132691, upload-time = "2026-04-01T21:47:44.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/7b/1ac9e1f753a19b6252434f0bbe96efdcc335cd74677f4c6f431a7c916114/uv-0.11.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3b1fe09d5e1d8e19459cd28d7825a3b66ef147b98328345bad6e17b87c4fea48", size = 22955764, upload-time = "2026-04-01T21:46:51.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/51/1a6010a681a3c3e0a8ec99737ba2d0452194dc372a5349a9267873261c02/uv-0.11.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:088165b9eed981d2c2a58566cc75dd052d613e47c65e2416842d07308f793a6f", size = 22966245, upload-time = "2026-04-01T21:47:07.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/74/1a1b0712daead7e85f56d620afe96fe166a04b615524c14027b4edd39b82/uv-0.11.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef0ae8ee2988928092616401ec7f473612b8e9589fe1567452c45dbc56840f85", size = 24623370, upload-time = "2026-04-01T21:47:03.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/62/5c3aa5e7bd2744810e50ad72a5951386ec84a513e109b1b5cb7ec442f3b6/uv-0.11.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6708827ecb846d00c5512a7e4dc751c2e27b92e9bd55a0be390561ac68930c32", size = 25142735, upload-time = "2026-04-01T21:46:55.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ab/6266a04980e0877af5518762adfe23a0c1ab0b801ae3099a2e7b74e34411/uv-0.11.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df030ea7563e99c09854e1bc82ab743dfa2d0ba18976e6861979cb40d04dba7", size = 24512083, upload-time = "2026-04-01T21:46:43.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/be/7c66d350f833eb437f9aa0875655cc05e07b441e3f4a770f8bced56133f7/uv-0.11.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fde893b5ab9f6997fe357138e794bac09d144328052519fbbe2e6f72145e457", size = 24589293, upload-time = "2026-04-01T21:47:11.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/4f/22ada41564a8c8c36653fc86f89faae4c54a4cdd5817bda53764a3eb352d/uv-0.11.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:45006bcd9e8718248a23ab81448a5beb46a72a9dd508e3212d6f3b8c63aeb88a", size = 23214854, upload-time = "2026-04-01T21:46:59.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/18/8669840657fea9fd668739dec89643afe1061c023c1488228b02f79a2399/uv-0.11.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:089b9d338a64463956b6fee456f03f73c9a916479bdb29009600781dc1e1d2a7", size = 23914434, upload-time = "2026-04-01T21:47:29.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/0d/c59f24b3a1ae5f377aa6fd9653562a0968ea6be946fe35761871a0072919/uv-0.11.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3ff461335888336467402cc5cb792c911df95dd0b52e369182cfa4c902bb21f4", size = 23971481, upload-time = "2026-04-01T21:47:48.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/7d/f83ed79921310ef216ed6d73fcd3822dff4b66749054fb97e09b7bd5901e/uv-0.11.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a62e29277efd39c35caf4a0fe739c4ebeb14d4ce4f02271f3f74271d608061ff", size = 23784797, upload-time = "2026-04-01T21:47:40.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/19/3ff3539c44ca7dc2aa87b021d4a153ba6a72866daa19bf91c289e4318f95/uv-0.11.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ebccdcdebd2b288925f0f7c18c39705dc783175952eacaf94912b01d3b381b86", size = 24794606, upload-time = "2026-04-01T21:47:36.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/e5/e676454bb7cc5dcf5c4637ed3ef0ff97309d84a149b832a4dea53f04c0ab/uv-0.11.3-py3-none-win32.whl", hash = "sha256:794aae3bab141eafbe37c51dc5dd0139658a755a6fa9cc74d2dbd7c71dcc4826", size = 22573432, upload-time = "2026-04-01T21:47:15.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a0/95d22d524bd3b4708043d65035f02fc9656e5fb6e0aaef73510313b1641b/uv-0.11.3-py3-none-win_amd64.whl", hash = "sha256:68fda574f2e5e7536a2b747dcea88329a71aad7222317e8f4717d0af8f99fbd4", size = 24969508, upload-time = "2026-04-01T21:47:19.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/3f0b90a06e8c4594e11f813651756d6896de6dd4461f554fd7e4984a1c4f/uv-0.11.3-py3-none-win_arm64.whl", hash = "sha256:92ffc4d521ab2c4738ef05d8ef26f2750e26d31f3ad5611cdfefc52445be9ace", size = 23488911, upload-time = "2026-04-01T21:47:52.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import ChainMap
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Sequence
|
||||
from os import getenv
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -217,14 +217,16 @@ def get_callback_manager_for_config(
|
||||
callbacks.add_tags(all_tags)
|
||||
if metadata := config.get("metadata"):
|
||||
callbacks.add_metadata(metadata)
|
||||
return callbacks
|
||||
manager = callbacks
|
||||
else:
|
||||
# otherwise create a new manager
|
||||
return CallbackManager.configure(
|
||||
manager = 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(
|
||||
@@ -255,14 +257,16 @@ def get_async_callback_manager_for_config(
|
||||
callbacks.add_tags(all_tags)
|
||||
if metadata := config.get("metadata"):
|
||||
callbacks.add_metadata(metadata)
|
||||
return callbacks
|
||||
manager = callbacks
|
||||
else:
|
||||
# otherwise create a new manager
|
||||
return AsyncCallbackManager.configure(
|
||||
manager = 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:
|
||||
@@ -308,22 +312,54 @@ 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
|
||||
_empty_metadata = empty["metadata"]
|
||||
for key, value in empty[CONF].items():
|
||||
if _exclude_as_metadata(key, value, _empty_metadata):
|
||||
continue
|
||||
_empty_metadata[key] = value
|
||||
|
||||
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
|
||||
return empty
|
||||
|
||||
|
||||
_OMIT = ("key", "token", "secret", "password", "auth")
|
||||
|
||||
|
||||
def _exclude_as_metadata(key: str, value: Any, metadata: Mapping[str, Any]) -> bool:
|
||||
def _exclude_as_metadata(key: str, value: 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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -66,6 +66,9 @@ CONFIG_KEY_RUNTIME = sys.intern("__pregel_runtime")
|
||||
# holds a `Runtime` instance with context, store, stream writer, etc.
|
||||
CONFIG_KEY_RESUME_MAP = sys.intern("__pregel_resume_map")
|
||||
# holds a mapping of task ns -> resume value for resuming tasks
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2 = sys.intern("__pregel_stream_messages_v2")
|
||||
# when True, attach StreamMessagesHandlerV2 so content-block (v2) events
|
||||
# flow through stream_mode="messages"; set by StreamingHandler only.
|
||||
|
||||
# --- Other constants ---
|
||||
PUSH = sys.intern("__pregel_push")
|
||||
@@ -107,6 +110,7 @@ RESERVED = {
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2,
|
||||
# other constants
|
||||
PUSH,
|
||||
PULL,
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""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 add_handler(
|
||||
self,
|
||||
handler: BaseCallbackHandler,
|
||||
inherit: bool = True, # noqa: FBT001,FBT002
|
||||
) -> None:
|
||||
if not isinstance(handler, GraphCallbackHandler):
|
||||
raise TypeError("handlers must inherit GraphCallbackHandler")
|
||||
super().add_handler(handler, inherit=inherit)
|
||||
|
||||
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 add_handler(
|
||||
self,
|
||||
handler: BaseCallbackHandler,
|
||||
inherit: bool = True, # noqa: FBT001,FBT002
|
||||
) -> None:
|
||||
if not isinstance(handler, GraphCallbackHandler):
|
||||
raise TypeError("handlers must inherit GraphCallbackHandler")
|
||||
super().add_handler(handler, inherit=inherit)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -1045,6 +1045,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
debug: bool = False,
|
||||
name: str | None = None,
|
||||
transformers: Sequence[Callable[[], Any]] | None = None,
|
||||
) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:
|
||||
"""Compiles the `StateGraph` into a `CompiledStateGraph` object.
|
||||
|
||||
@@ -1077,6 +1078,11 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: An optional list of node names to interrupt after.
|
||||
debug: A flag indicating whether to enable debug mode.
|
||||
name: The name to use for the compiled graph.
|
||||
transformers: Optional sequence of zero-arg factories returning
|
||||
`StreamTransformer` instances. Registered on the compiled
|
||||
graph and instantiated per-run whenever `stream_v2` /
|
||||
`astream_v2` is called. Appended after the built-in
|
||||
`ValuesTransformer` and `MessagesTransformer`.
|
||||
|
||||
Returns:
|
||||
CompiledStateGraph: The compiled `StateGraph`.
|
||||
@@ -1159,6 +1165,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
store=store,
|
||||
cache=cache,
|
||||
name=name or "LangGraph",
|
||||
stream_transformers=transformers,
|
||||
)
|
||||
compiled._serde_allowlist = serde_allowlist
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@ 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
|
||||
@@ -117,6 +122,7 @@ from langgraph.types import (
|
||||
CachePolicy,
|
||||
Command,
|
||||
Durability,
|
||||
Interrupt,
|
||||
PregelExecutableTask,
|
||||
RetryPolicy,
|
||||
Send,
|
||||
@@ -203,6 +209,8 @@ 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
|
||||
|
||||
@@ -228,6 +236,7 @@ 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
|
||||
@@ -252,6 +261,8 @@ 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)
|
||||
@@ -303,6 +314,40 @@ 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:
|
||||
@@ -785,6 +830,8 @@ 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:
|
||||
@@ -885,8 +932,10 @@ class PregelLoop:
|
||||
self._put_checkpoint(self.checkpoint_metadata)
|
||||
self._put_pending_writes()
|
||||
# suppress interrupt
|
||||
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
|
||||
if suppress:
|
||||
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)
|
||||
# emit one last "values" event, with pending writes applied
|
||||
if (
|
||||
hasattr(self, "tasks")
|
||||
@@ -913,12 +962,11 @@ class PregelLoop:
|
||||
self.channels,
|
||||
)
|
||||
# emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
|
||||
if exc_value is not None and (not exc_value.args or not exc_value.args[0]):
|
||||
if not interrupt.args or not interrupt.args[0]:
|
||||
interrupt_payload = interrupt.args[0] if interrupt.args else ()
|
||||
self._emit(
|
||||
"updates",
|
||||
lambda: iter(
|
||||
[{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]
|
||||
),
|
||||
lambda: iter([{INTERRUPT: interrupt_payload}]),
|
||||
)
|
||||
# save final output
|
||||
self.output = read_channels(self.channels, self.output_keys)
|
||||
@@ -1040,6 +1088,7 @@ 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,
|
||||
@@ -1061,6 +1110,7 @@ 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:
|
||||
@@ -1136,6 +1186,7 @@ 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):
|
||||
@@ -1236,6 +1287,7 @@ 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,
|
||||
@@ -1257,6 +1309,7 @@ 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:
|
||||
@@ -1335,6 +1388,7 @@ 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):
|
||||
|
||||
@@ -14,7 +14,7 @@ from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph._internal._constants import NS_END, NS_SEP
|
||||
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
|
||||
from langgraph.pregel.protocol import StreamChunk
|
||||
from langgraph.types import Command
|
||||
@@ -24,6 +24,11 @@ try:
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = object # type: ignore
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _V2StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_V2StreamingCallbackHandler = object # type: ignore
|
||||
|
||||
T = TypeVar("T")
|
||||
Meta = tuple[tuple[str, ...], dict[str, Any]]
|
||||
|
||||
@@ -132,15 +137,23 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
**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
|
||||
]
|
||||
task_checkpoint_ns = cast(str, metadata["langgraph_checkpoint_ns"])
|
||||
checkpoint_ns = (
|
||||
f"{task_checkpoint_ns.rsplit(NS_END, 1)[0]}{NS_END}"
|
||||
if NS_END in task_checkpoint_ns
|
||||
else task_checkpoint_ns
|
||||
)
|
||||
ns = tuple(task_checkpoint_ns.split(NS_SEP))[:-1]
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return
|
||||
stream_metadata = dict(metadata)
|
||||
stream_metadata["langgraph_checkpoint_ns"] = checkpoint_ns
|
||||
# Preserve backwards-compatible streamed checkpoint metadata shape.
|
||||
stream_metadata["checkpoint_ns"] = checkpoint_ns
|
||||
if tags:
|
||||
if filtered_tags := [t for t in tags if not t.startswith("seq:step")]:
|
||||
metadata["tags"] = filtered_tags
|
||||
self.metadata[run_id] = (ns, metadata)
|
||||
stream_metadata["tags"] = filtered_tags
|
||||
self.metadata[run_id] = (ns, stream_metadata)
|
||||
|
||||
def on_llm_new_token(
|
||||
self,
|
||||
@@ -248,3 +261,78 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self.metadata.pop(run_id, None)
|
||||
|
||||
|
||||
class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler):
|
||||
"""v2 variant of `StreamMessagesHandler`.
|
||||
|
||||
Declaring `_V2StreamingCallbackHandler` as a base flips
|
||||
`BaseChatModel.invoke` to route through `_stream_chat_model_events`
|
||||
(firing `on_stream_event`) instead of `_stream` (firing
|
||||
`on_llm_new_token`). Inherits `on_stream_event` from the parent,
|
||||
which forwards protocol events onto the messages stream channel.
|
||||
|
||||
Pregel attaches this class instead of the v1 handler only when
|
||||
`StreamingHandler` opts in via the internal
|
||||
`CONFIG_KEY_STREAM_MESSAGES_V2` config key; direct
|
||||
`graph.stream(stream_mode="messages")` callers keep the v1
|
||||
AIMessageChunk shape.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Intentional no-op — v1 chunks are not used on v2-flagged runs.
|
||||
|
||||
The v2 marker already steers `invoke` to the event generator, so
|
||||
`on_llm_new_token` should not fire under normal routing. This
|
||||
override stays a pass-through (no call to `super()`) to make
|
||||
the intent explicit and to guard against any caller (e.g. a
|
||||
node that calls `model.stream()` directly, which still fires
|
||||
the v1 callback) leaking AIMessageChunks onto a v2-flagged
|
||||
messages stream.
|
||||
"""
|
||||
# Intentionally empty: v2 handler does not forward v1 chunks.
|
||||
|
||||
def on_stream_event(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Forward a protocol event from `stream_v2` as a messages stream part.
|
||||
|
||||
Fires once per `MessagesData` event (`message-start`, per-block
|
||||
`content-block-*`, `message-finish`). The transformer layer
|
||||
correlates events back to a single `ChatModelStream` via
|
||||
`metadata["run_id"]` — attached here so the v1
|
||||
`stream_mode="messages"` output (which emits
|
||||
`(AIMessageChunk, metadata)` via `on_llm_new_token`) keeps its
|
||||
original metadata shape.
|
||||
|
||||
Lives on the v2 handler rather than the v1 base: content-block
|
||||
events are a v2-only concept, and forwarding them only when the
|
||||
v2 handler is attached keeps the message channel's shape
|
||||
predictable for v1 callers.
|
||||
"""
|
||||
if meta := self.metadata.get(run_id):
|
||||
# Record message_id on message-start so on_chain_end's
|
||||
# dedupe skips the finalized AIMessage the node returns
|
||||
# (otherwise the messages projection double-counts: once
|
||||
# from streaming, once from the chain output).
|
||||
if event.get("event") == "message-start":
|
||||
msg_id = event.get("message_id")
|
||||
if msg_id:
|
||||
self.seen.add(msg_id)
|
||||
v2_meta = {**meta[1], "run_id": str(run_id)}
|
||||
self.stream((meta[0], "messages", (event, v2_meta)))
|
||||
|
||||
@@ -1,807 +0,0 @@
|
||||
"""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"]
|
||||
@@ -16,7 +16,7 @@ from collections.abc import (
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from dataclasses import is_dataclass
|
||||
from dataclasses import is_dataclass, replace
|
||||
from functools import partial
|
||||
from inspect import isclass
|
||||
from typing import (
|
||||
@@ -73,6 +73,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
ERROR,
|
||||
@@ -96,6 +97,12 @@ 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
|
||||
@@ -127,10 +134,9 @@ from langgraph.pregel._loop import (
|
||||
AsyncPregelLoop,
|
||||
SyncPregelLoop,
|
||||
)
|
||||
from langgraph.pregel._messages import StreamMessagesHandler
|
||||
from langgraph.pregel._messages_v2 import (
|
||||
PROTOCOL_MESSAGES_STREAM_KEY,
|
||||
StreamProtocolMessagesHandler,
|
||||
from langgraph.pregel._messages import (
|
||||
StreamMessagesHandler,
|
||||
StreamMessagesHandlerV2,
|
||||
)
|
||||
from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
|
||||
from langgraph.pregel._retry import RetryPolicy
|
||||
@@ -338,6 +344,69 @@ class NodeBuilder:
|
||||
)
|
||||
|
||||
|
||||
_STREAM_V2_MODES: list[StreamMode] = [
|
||||
"values",
|
||||
"updates",
|
||||
"messages",
|
||||
"custom",
|
||||
"checkpoints",
|
||||
"tasks",
|
||||
"debug",
|
||||
]
|
||||
|
||||
|
||||
def _build_stream_factories(
|
||||
compile_time: Sequence[Callable[..., Any]],
|
||||
call_site: Sequence[Any] | None,
|
||||
) -> list[Callable[..., Any]]:
|
||||
"""Assemble the factory list handed to `StreamMux(factories=...)`.
|
||||
|
||||
Prepends the auto-registered built-ins — `ValuesTransformer`
|
||||
(state snapshots backing `run.output` / `run.interrupted`),
|
||||
`MessagesTransformer` (LLM token streaming), and
|
||||
`SubgraphTransformer` (in-process subgraph handle discovery) —
|
||||
then appends the graph's compile-time `stream_transformers`
|
||||
followed by any call-site additions. Factories flow down into
|
||||
subgraph mini-muxes, so per-scope instances propagate
|
||||
automatically.
|
||||
|
||||
`LifecycleTransformer` is opt-in: add it via compile-time
|
||||
`stream_transformers=[...]` or the per-call `transformers=[...]`
|
||||
kwarg on `stream_v2()` / `astream_v2()`. Without it, no
|
||||
`lifecycle` wire events are emitted and `run.lifecycle` is
|
||||
absent.
|
||||
"""
|
||||
from langgraph.stream.transformers import (
|
||||
MessagesTransformer,
|
||||
SubgraphTransformer,
|
||||
ValuesTransformer,
|
||||
)
|
||||
|
||||
builtins: list[Callable[..., Any]] = [
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
SubgraphTransformer,
|
||||
]
|
||||
return [*builtins, *compile_time, *(call_site or ())]
|
||||
|
||||
|
||||
def _merge_v2_messages_flag(
|
||||
config: RunnableConfig | None,
|
||||
) -> RunnableConfig:
|
||||
"""Return a config with the v2 messages flag set in `configurable`.
|
||||
|
||||
Signals to pregel that `stream_mode="messages"` should attach
|
||||
`StreamMessagesHandlerV2` for this call so invoke-time model runs
|
||||
route through the v2 event generator and their protocol events
|
||||
reach the messages channel.
|
||||
"""
|
||||
merged: RunnableConfig = dict(config or {}) # type: ignore[assignment]
|
||||
configurable = dict(merged.get(CONF) or {})
|
||||
configurable[CONFIG_KEY_STREAM_MESSAGES_V2] = True
|
||||
merged[CONF] = configurable
|
||||
return merged
|
||||
|
||||
|
||||
class Pregel(
|
||||
PregelProtocol[StateT, ContextT, InputT, OutputT],
|
||||
Generic[StateT, ContextT, InputT, OutputT],
|
||||
@@ -669,6 +738,7 @@ class Pregel(
|
||||
config: RunnableConfig | None = None,
|
||||
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
|
||||
name: str = "LangGraph",
|
||||
stream_transformers: Sequence[Callable[..., Any]] | None = None,
|
||||
**deprecated_kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> None:
|
||||
if (
|
||||
@@ -715,6 +785,9 @@ class Pregel(
|
||||
self.config = config
|
||||
self.trigger_to_nodes = trigger_to_nodes or {}
|
||||
self.name = name
|
||||
self._stream_transformers: tuple[Callable[..., Any], ...] = tuple(
|
||||
stream_transformers or ()
|
||||
)
|
||||
self._serde_allowlist: set[tuple[str, ...]] | None = None
|
||||
if auto_validate:
|
||||
self.validate()
|
||||
@@ -2589,6 +2662,10 @@ 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
|
||||
(
|
||||
@@ -2620,15 +2697,13 @@ 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
|
||||
)
|
||||
messages_handler_cls = (
|
||||
StreamMessagesHandlerV2
|
||||
if config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
||||
else StreamMessagesHandler
|
||||
)
|
||||
run_manager.inheritable_handlers.append(
|
||||
_msg_cls(
|
||||
messages_handler_cls(
|
||||
stream.put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
@@ -2680,6 +2755,17 @@ 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),
|
||||
@@ -2700,7 +2786,9 @@ 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(
|
||||
@@ -2762,9 +2850,11 @@ 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,
|
||||
@@ -2939,6 +3029,10 @@ 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(
|
||||
@@ -2946,10 +3040,7 @@ class Pregel(
|
||||
True
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
and not isinstance(
|
||||
h,
|
||||
(StreamMessagesHandler, StreamProtocolMessagesHandler),
|
||||
)
|
||||
and not isinstance(h, StreamMessagesHandler)
|
||||
),
|
||||
False,
|
||||
)
|
||||
@@ -2986,16 +3077,15 @@ 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
|
||||
)
|
||||
messages_handler_cls = (
|
||||
StreamMessagesHandlerV2
|
||||
if config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
||||
else StreamMessagesHandler
|
||||
)
|
||||
run_manager.inheritable_handlers.append(
|
||||
_msg_cls(
|
||||
messages_handler_cls(
|
||||
stream_put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
@@ -3062,6 +3152,28 @@ 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),
|
||||
@@ -3082,7 +3194,9 @@ 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(
|
||||
@@ -3164,6 +3278,7 @@ 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)
|
||||
@@ -3172,6 +3287,8 @@ 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,
|
||||
@@ -3201,6 +3318,98 @@ class Pregel(
|
||||
await asyncio.shield(run_manager.on_chain_error(e))
|
||||
raise
|
||||
|
||||
def stream_v2(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
transformers: Sequence[Any] | None = None,
|
||||
) -> Any:
|
||||
"""Start a sync v2 streaming run driven by transformer projections.
|
||||
|
||||
Builds a `StreamMux` from the auto-registered built-ins
|
||||
(`ValuesTransformer`, `MessagesTransformer`,
|
||||
`SubgraphTransformer`), this graph's compile-time
|
||||
`stream_transformers`, and any additional `transformers=`
|
||||
supplied at the call site. Returns a `GraphRunStream` that
|
||||
the caller drives by iterating any projection — no background
|
||||
thread.
|
||||
|
||||
`LifecycleTransformer` (emits `lifecycle` wire events,
|
||||
exposes `run.lifecycle`) is opt-in — add it via
|
||||
`stream_transformers` at compile time or via the
|
||||
`transformers=` kwarg here.
|
||||
|
||||
Args:
|
||||
input: Graph input.
|
||||
config: Optional runnable config forwarded to the graph.
|
||||
interrupt_before: Nodes to interrupt before, if any.
|
||||
interrupt_after: Nodes to interrupt after, if any.
|
||||
transformers: Extra transformer instances appended after
|
||||
compile-time `stream_transformers`.
|
||||
|
||||
Returns:
|
||||
A `GraphRunStream` the caller iterates to drive the run.
|
||||
"""
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream.run_stream import GraphRunStream
|
||||
|
||||
factories = _build_stream_factories(self._stream_transformers, transformers)
|
||||
mux = StreamMux(factories=factories, is_async=False)
|
||||
graph_iter = iter(
|
||||
self.stream(
|
||||
input,
|
||||
_merge_v2_messages_flag(config),
|
||||
stream_mode=_STREAM_V2_MODES,
|
||||
subgraphs=True,
|
||||
version="v2",
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
)
|
||||
)
|
||||
return GraphRunStream(graph_iter, mux)
|
||||
|
||||
async def astream_v2(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
transformers: Sequence[Any] | None = None,
|
||||
) -> Any:
|
||||
"""Async counterpart to `stream_v2`.
|
||||
|
||||
Returns an `AsyncGraphRunStream` whose projections can be awaited
|
||||
concurrently; each subscribed cursor drives the pump when its
|
||||
buffer is empty.
|
||||
|
||||
Args:
|
||||
input: Graph input.
|
||||
config: Optional runnable config forwarded to the graph.
|
||||
interrupt_before: Nodes to interrupt before, if any.
|
||||
interrupt_after: Nodes to interrupt after, if any.
|
||||
transformers: Extra transformer instances appended after
|
||||
compile-time `stream_transformers`.
|
||||
"""
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream.run_stream import AsyncGraphRunStream
|
||||
|
||||
factories = _build_stream_factories(self._stream_transformers, transformers)
|
||||
mux = StreamMux(factories=factories, is_async=True)
|
||||
graph_aiter = self.astream(
|
||||
input,
|
||||
_merge_v2_messages_flag(config),
|
||||
stream_mode=_STREAM_V2_MODES,
|
||||
subgraphs=True,
|
||||
version="v2",
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
).__aiter__()
|
||||
return AsyncGraphRunStream(graph_aiter, mux)
|
||||
|
||||
@overload
|
||||
def invoke(
|
||||
self,
|
||||
@@ -3679,15 +3888,14 @@ 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 metadata and configurable.
|
||||
"""Build ServerInfo from config configurable.
|
||||
|
||||
The server puts assistant_id/graph_id in config metadata and the
|
||||
The server puts assistant_id/graph_id in config configurable and the
|
||||
authenticated user dict in configurable["langgraph_auth_user"].
|
||||
"""
|
||||
metadata = config.get("metadata") or {}
|
||||
configurable = config.get(CONF) or {}
|
||||
assistant_id = metadata.get("assistant_id")
|
||||
graph_id = metadata.get("graph_id")
|
||||
assistant_id = configurable.get("assistant_id")
|
||||
graph_id = configurable.get("graph_id")
|
||||
|
||||
# Read authenticated user from configurable (set by LangGraph Server).
|
||||
# We prefer isinstance(BaseUser) but fall back to hasattr("identity")
|
||||
|
||||
@@ -653,10 +653,11 @@ class RemoteGraph(PregelProtocol):
|
||||
# coerce to list, or add default stream mode
|
||||
if stream_mode:
|
||||
if isinstance(stream_mode, str):
|
||||
updated_stream_modes.append(stream_mode)
|
||||
updated_stream_modes.append(cast(StreamModeSDK, stream_mode))
|
||||
else:
|
||||
req_single = False
|
||||
updated_stream_modes.extend(stream_mode)
|
||||
for m in stream_mode:
|
||||
updated_stream_modes.append(cast(StreamModeSDK, m))
|
||||
else:
|
||||
updated_stream_modes.append(default)
|
||||
requested_stream_modes = updated_stream_modes.copy()
|
||||
@@ -665,7 +666,8 @@ class RemoteGraph(PregelProtocol):
|
||||
(config or {}).get(CONF, {}).get(CONFIG_KEY_STREAM)
|
||||
)
|
||||
if stream:
|
||||
updated_stream_modes.extend(stream.modes)
|
||||
for m in stream.modes:
|
||||
updated_stream_modes.append(cast(StreamModeSDK, m))
|
||||
# map "messages" to "messages-tuple"
|
||||
if "messages" in updated_stream_modes:
|
||||
updated_stream_modes.remove("messages")
|
||||
|
||||
@@ -1,45 +1,20 @@
|
||||
"""Stream protocol types and infrastructure for LangGraph."""
|
||||
"""Streaming infrastructure for LangGraph.
|
||||
|
||||
from langgraph.stream._convert import STREAM_V2_MODES, convert_to_protocol_event
|
||||
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,
|
||||
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,
|
||||
)
|
||||
Compile a graph with `transformers=[...]` and call `graph.stream_v2()` /
|
||||
`graph.astream_v2()` to drive a transformer pipeline that projects the
|
||||
graph's raw events into ergonomic per-channel streams.
|
||||
"""
|
||||
|
||||
from langgraph.stream._event_log import EventLog
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
__all__ = [
|
||||
"STREAM_V2_MODES",
|
||||
"AsyncChatModelStream",
|
||||
"AsyncGraphRunStream",
|
||||
"AsyncStreamMux",
|
||||
"AsyncSubgraphRunStream",
|
||||
"ChatModelStream",
|
||||
"EventLog",
|
||||
"GraphRunStream",
|
||||
"InterruptPayload",
|
||||
"MessagesTransformer",
|
||||
"ProtocolEvent",
|
||||
"StreamChannel",
|
||||
"StreamMux",
|
||||
"StreamTransformer",
|
||||
"StreamingHandler",
|
||||
"ValuesTransformer",
|
||||
"convert_to_protocol_event",
|
||||
"create_async_graph_run_stream",
|
||||
"create_graph_run_stream",
|
||||
"is_stream_channel",
|
||||
]
|
||||
|
||||
@@ -1,67 +1,32 @@
|
||||
"""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
|
||||
|
||||
from typing import Any
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
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)
|
||||
from langgraph.types import StreamPart
|
||||
|
||||
|
||||
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()``.
|
||||
def convert_to_protocol_event(part: StreamPart) -> ProtocolEvent:
|
||||
"""Convert a v2 StreamPart to a ProtocolEvent.
|
||||
|
||||
Args:
|
||||
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.
|
||||
part: A stream part with keys `type`, `ns`, `data`, and
|
||||
optionally `interrupts` (present on values events).
|
||||
|
||||
Returns:
|
||||
The equivalent ProtocolEvent.
|
||||
"""
|
||||
if mode not in _SUPPORTED_MODES:
|
||||
return None
|
||||
|
||||
part_dict = cast(dict[str, Any], part)
|
||||
params: _ProtocolEventParams = {
|
||||
"namespace": list(ns),
|
||||
"data": payload,
|
||||
"namespace": list(part_dict["ns"]),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": part_dict["data"],
|
||||
}
|
||||
if "interrupts" in part_dict:
|
||||
params["interrupts"] = part_dict["interrupts"]
|
||||
return {
|
||||
"type": "event",
|
||||
"method": part_dict["type"],
|
||||
"params": params,
|
||||
}
|
||||
if node is not None:
|
||||
params["node"] = node
|
||||
|
||||
return ProtocolEvent(
|
||||
type="event",
|
||||
method=mode,
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["STREAM_V2_MODES", "convert_to_protocol_event"]
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class EventLog(Generic[T]):
|
||||
"""Single-consumer drainable queue for streaming events.
|
||||
|
||||
Items are popped off the front as the consumer advances — there is
|
||||
no retention beyond what's currently queued. A log accepts exactly
|
||||
one subscriber; a second `__iter__` / `__aiter__` call raises. Use
|
||||
`tee(n)` / `atee(n)` for fan-out.
|
||||
|
||||
Starts unbound — neither `__iter__` nor `__aiter__` is available
|
||||
until the StreamMux calls `_bind(is_async)`. After binding, only
|
||||
the matching iteration protocol works; the other raises `TypeError`.
|
||||
|
||||
Pump wiring (set by the run stream, not by `_bind`):
|
||||
- `_request_more`: sync pump callable, returns True if a new
|
||||
event was produced.
|
||||
- `_arequest_more`: async pump coroutine factory, same contract.
|
||||
|
||||
Memory is bounded by caller pace: both sync and async use caller-
|
||||
driven pumps, so each cursor advance produces at most one event.
|
||||
The only shape where a log can accumulate meaningfully is
|
||||
concurrent async consumers at unequal rates — a slow consumer's
|
||||
log grows while fast consumers drive the shared pump. That's the
|
||||
documented tradeoff for concurrent consumption; consume at similar
|
||||
rates or use a single consumer if memory matters.
|
||||
|
||||
Lazy-subscribe: `push` is a no-op when no subscriber has registered.
|
||||
Transformers still execute `process()` (so scalar state like
|
||||
`ValuesTransformer._latest` stays current); only the log append is
|
||||
skipped.
|
||||
"""
|
||||
|
||||
def __init__(self, maxlen: int | None = None, *, retain: bool = False) -> None:
|
||||
"""Initialize an empty, unbound log.
|
||||
|
||||
Args:
|
||||
maxlen: Accepted for forward compatibility; currently unused.
|
||||
The caller-driven pump bounds memory naturally for
|
||||
single-consumer use.
|
||||
retain: If True, `push()` retains items regardless of whether
|
||||
a consumer has subscribed yet. Used for projections
|
||||
whose consumer only becomes visible after events have
|
||||
already flowed (e.g. mini-mux logs inside dynamically
|
||||
discovered subgraph handles, or the `lifecycle` channel
|
||||
iterated after draining `values`). Subscription
|
||||
exclusivity on `__iter__` is unchanged.
|
||||
|
||||
Raises:
|
||||
ValueError: If `maxlen` is not a positive integer or `None`.
|
||||
"""
|
||||
if maxlen is not None and maxlen <= 0:
|
||||
raise ValueError("EventLog maxlen must be a positive int or None")
|
||||
self._items: deque[T] = deque()
|
||||
self._maxlen: int | None = maxlen
|
||||
self._closed = False
|
||||
self._error: BaseException | None = None
|
||||
|
||||
# Binding state — None means unbound.
|
||||
self._is_async: bool | None = None
|
||||
|
||||
# Flipped on first __iter__ / __aiter__. Pre-subscription
|
||||
# pushes are silent no-ops unless `_retain` is True.
|
||||
self._subscribed = False
|
||||
self._retain = retain
|
||||
|
||||
# Pump wiring set by the run stream after bind.
|
||||
self._request_more: Callable[[], bool] | None = None
|
||||
self._arequest_more: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Binding
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _bind(self, *, is_async: bool) -> None:
|
||||
"""Bind this log to sync or async mode.
|
||||
|
||||
Called by the StreamMux after transformer registration. Must be
|
||||
called exactly once before any iteration.
|
||||
|
||||
Args:
|
||||
is_async: True to enable async iteration, False for sync.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the log has already been bound.
|
||||
"""
|
||||
if self._is_async is not None:
|
||||
raise RuntimeError("EventLog is already bound")
|
||||
self._is_async = is_async
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Producer API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def push(self, item: T) -> None:
|
||||
"""Append an item. No-op when no subscriber is registered.
|
||||
|
||||
Non-blocking in both sync and async — matches v1's
|
||||
`put_nowait` producer shape. Memory is bounded by caller pace
|
||||
via the caller-driven pump.
|
||||
|
||||
When `retain=True` was set at construction, items are appended
|
||||
regardless of subscription — for projections whose consumer
|
||||
only reaches them after events have already flowed.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the log is closed (and subscribed).
|
||||
"""
|
||||
if not self._subscribed and not self._retain:
|
||||
return
|
||||
if self._closed:
|
||||
raise RuntimeError("Cannot push to a closed EventLog")
|
||||
self._items.append(item)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Mark the log as complete."""
|
||||
self._closed = True
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Mark the log as errored.
|
||||
|
||||
Args:
|
||||
err: The exception to surface to the subscriber.
|
||||
"""
|
||||
self._error = err
|
||||
self._closed = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync iteration (caller-driven pump)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
"""Subscribe and return a sync cursor. Can be called only once.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to async mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
"""
|
||||
if self._is_async is None:
|
||||
raise TypeError(
|
||||
"EventLog has not been bound yet. "
|
||||
"Register the transformer with a StreamMux first."
|
||||
)
|
||||
if self._is_async:
|
||||
raise TypeError(
|
||||
"This EventLog is bound to async mode — use 'async for' instead."
|
||||
)
|
||||
if self._subscribed:
|
||||
raise RuntimeError(
|
||||
"EventLog already has a subscriber; use .tee(n) for fan-out."
|
||||
)
|
||||
self._subscribed = True
|
||||
return self._sync_cursor()
|
||||
|
||||
def _sync_cursor(self) -> Iterator[T]:
|
||||
while True:
|
||||
if self._items:
|
||||
yield self._items.popleft()
|
||||
elif self._closed:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return
|
||||
elif self._request_more is not None:
|
||||
if not self._request_more():
|
||||
if not self._items and not self._closed:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async iteration (caller-driven pump)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[T]:
|
||||
"""Subscribe and return an async cursor. Can be called only once.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to sync mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
"""
|
||||
if self._is_async is None:
|
||||
raise TypeError(
|
||||
"EventLog has not been bound yet. "
|
||||
"Register the transformer with a StreamMux first."
|
||||
)
|
||||
if not self._is_async:
|
||||
raise TypeError("This EventLog is bound to sync mode — use 'for' instead.")
|
||||
if self._subscribed:
|
||||
raise RuntimeError(
|
||||
"EventLog already has a subscriber; use .atee(n) for fan-out."
|
||||
)
|
||||
self._subscribed = True
|
||||
return self._async_cursor()
|
||||
|
||||
async def _async_cursor(self) -> AsyncIterator[T]:
|
||||
while True:
|
||||
if self._items:
|
||||
yield self._items.popleft()
|
||||
elif self._closed:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return
|
||||
elif self._arequest_more is not None:
|
||||
if not await self._arequest_more():
|
||||
if not self._items and not self._closed:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fan-out via tee
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def tee(self, n: int = 2) -> tuple[Iterator[T], ...]:
|
||||
"""Subscribe and return `n` independent sync iterators.
|
||||
|
||||
Each branch has its own buffer; items pulled from the
|
||||
underlying cursor are copied into every branch. Branches are
|
||||
naturally bounded by caller pace since the sync pump is
|
||||
caller-driven.
|
||||
|
||||
Args:
|
||||
n: Number of branches to create. Must be >= 1.
|
||||
|
||||
Returns:
|
||||
A tuple of `n` iterators over the same underlying stream.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to async mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
ValueError: If `n` < 1.
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError("tee() requires n >= 1")
|
||||
source = self.__iter__()
|
||||
buffers: list[deque[T]] = [deque() for _ in range(n)]
|
||||
exhausted = [False]
|
||||
|
||||
def branch(i: int) -> Iterator[T]:
|
||||
buf = buffers[i]
|
||||
while True:
|
||||
if buf:
|
||||
yield buf.popleft()
|
||||
elif exhausted[0]:
|
||||
return
|
||||
else:
|
||||
try:
|
||||
item = next(source)
|
||||
except StopIteration:
|
||||
exhausted[0] = True
|
||||
return
|
||||
for b in buffers:
|
||||
b.append(item)
|
||||
|
||||
return tuple(branch(i) for i in range(n))
|
||||
|
||||
def atee(self, n: int = 2) -> tuple[AsyncIterator[T], ...]:
|
||||
"""Subscribe and return `n` independent async iterators.
|
||||
|
||||
Caller-driven fan-out: each branch's `__anext__` either pops
|
||||
from its own buffer or, under a shared `asyncio.Lock`, pulls
|
||||
one item from the underlying cursor and distributes it to
|
||||
every branch's buffer.
|
||||
|
||||
Args:
|
||||
n: Number of branches to create. Must be >= 1.
|
||||
|
||||
Returns:
|
||||
A tuple of `n` async iterators over the same underlying
|
||||
stream.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to sync mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
ValueError: If `n` < 1.
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError("atee() requires n >= 1")
|
||||
source = self.__aiter__()
|
||||
buffers: list[deque[T]] = [deque() for _ in range(n)]
|
||||
exhausted = [False]
|
||||
error: list[BaseException | None] = [None]
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def branch(i: int) -> AsyncIterator[T]:
|
||||
buf = buffers[i]
|
||||
while True:
|
||||
if buf:
|
||||
yield buf.popleft()
|
||||
continue
|
||||
if exhausted[0]:
|
||||
if error[0] is not None:
|
||||
raise error[0]
|
||||
return
|
||||
async with lock:
|
||||
if buf or exhausted[0]:
|
||||
continue
|
||||
try:
|
||||
item = await source.__anext__()
|
||||
except StopAsyncIteration:
|
||||
exhausted[0] = True
|
||||
continue
|
||||
except Exception as e:
|
||||
error[0] = e
|
||||
exhausted[0] = True
|
||||
continue
|
||||
for b in buffers:
|
||||
b.append(item)
|
||||
|
||||
return tuple(branch(i) for i in range(n))
|
||||
@@ -1,385 +1,514 @@
|
||||
"""Central event dispatcher with transformer pipeline for StreamingHandler.
|
||||
|
||||
``StreamMux`` is the synchronous core: it holds the main
|
||||
event log (a plain list), 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 ``StreamMux`` with async consumer APIs
|
||||
(output futures, async event subscriptions, subgraph discovery).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langgraph.stream._types import InterruptPayload, ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.stream_channel import StreamChannel, is_stream_channel
|
||||
from langgraph.stream._event_log import EventLog
|
||||
from langgraph.stream._types import (
|
||||
ProtocolEvent,
|
||||
StreamTransformer,
|
||||
transformer_requires_async,
|
||||
)
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
TransformerFactory = Callable[["tuple[str, ...]"], StreamTransformer]
|
||||
"""Factory that builds a scoped transformer for a mux.
|
||||
|
||||
Called once per `StreamMux` (root or mini-mux) with the mux's scope
|
||||
— typically a subgraph's namespace or `()` for the root. Standard
|
||||
transformer classes (`ValuesTransformer`, `MessagesTransformer`,
|
||||
`SubgraphTransformer`) accept a single positional scope argument, so
|
||||
the class itself is a valid factory. User transformers can close over
|
||||
their config: `lambda scope: MyTransformer(scope, foo=...)`.
|
||||
"""
|
||||
|
||||
|
||||
class StreamMux:
|
||||
"""Synchronous event dispatcher for the StreamingHandler infrastructure.
|
||||
"""Central event dispatcher for the streaming infrastructure.
|
||||
|
||||
The mux owns the main event log, applies the transformer pipeline to
|
||||
every incoming event, and tracks namespace discovery and latest values.
|
||||
Owns the main event log and routes events through a transformer
|
||||
pipeline. StreamChannels discovered in transformer projections are
|
||||
auto-wired so that every `push()` also injects a `ProtocolEvent`
|
||||
into the main log.
|
||||
|
||||
For async consumer APIs (output futures, async event subscriptions,
|
||||
subgraph discovery) use :class:`AsyncStreamMux`.
|
||||
Pass `is_async=True` when the mux will be consumed via async
|
||||
iteration (`handler.astream()`). All EventLog and StreamChannel
|
||||
instances discovered during registration are automatically bound
|
||||
to the matching mode.
|
||||
|
||||
Attributes:
|
||||
extensions: Merged projection dict across all registered
|
||||
transformers. Treat as read-only — mutations won't be
|
||||
reflected back in individual transformers' state.
|
||||
native_keys: Projection keys contributed by transformers with
|
||||
`_native = True`.
|
||||
"""
|
||||
|
||||
def __init__(self, transformers: list[StreamTransformer] | None = None) -> None:
|
||||
self._event_log: list[ProtocolEvent] = []
|
||||
self._transformers: list[StreamTransformer] = list(transformers or [])
|
||||
self._current_namespace: list[str] = []
|
||||
self._next_emit_seq: int = 0
|
||||
def __init__(
|
||||
self,
|
||||
transformers: list[StreamTransformer] | None = None,
|
||||
*,
|
||||
is_async: bool = False,
|
||||
factories: list[TransformerFactory] | None = None,
|
||||
scope: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
"""Initialize the mux and register transformers in order.
|
||||
|
||||
# Namespace discovery: maps top-level ns segment → True
|
||||
self._discovered_ns: dict[str, bool] = {}
|
||||
Callers pass either `transformers` (pre-built instances) or
|
||||
`factories` (callables producing fresh instances per mux). A
|
||||
factory list is preferred — mini-muxes built by `make_child()`
|
||||
inherit the factory list, so transformers propagate naturally
|
||||
into every subgraph's scope. `transformers` is kept for
|
||||
back-compat tests that exercise the mux directly.
|
||||
|
||||
# Latest values per namespace (list-of-strings key)
|
||||
self._latest_values: dict[str, Any] = {}
|
||||
Each transformer's `init()` is called once during registration,
|
||||
projections are merged into `extensions`, `_native` keys are
|
||||
recorded in `native_keys`, and any EventLog / StreamChannel
|
||||
instances are bound and wired.
|
||||
|
||||
# Interrupt tracking
|
||||
self._interrupts: list[InterruptPayload] = []
|
||||
self._interrupted = False
|
||||
Args:
|
||||
transformers: Already-built transformer instances. Mutually
|
||||
exclusive with `factories`.
|
||||
is_async: True for async dispatch (`apush` / `aclose` /
|
||||
`afail`), False for the sync path.
|
||||
factories: Zero-or-one-argument callables producing
|
||||
transformers. Called with this mux's `scope`.
|
||||
scope: The namespace the mux operates within. The root mux
|
||||
is `()`; mini-muxes for subgraphs use the subgraph's
|
||||
namespace tuple.
|
||||
|
||||
# Closed state
|
||||
self._closed = False
|
||||
self._error: BaseException | None = None
|
||||
Raises:
|
||||
RuntimeError: If any transformer requires an async run but
|
||||
the mux is in sync mode.
|
||||
TypeError: If a transformer's `init()` doesn't return a dict.
|
||||
ValueError: If transformers' projection keys collide, or if
|
||||
both `transformers` and `factories` are supplied.
|
||||
"""
|
||||
if transformers is not None and factories is not None:
|
||||
raise ValueError("Pass either `transformers` or `factories`, not both.")
|
||||
|
||||
# -- Producer API -------------------------------------------------------
|
||||
self._is_async = is_async
|
||||
self._factories: list[TransformerFactory] = list(factories or ())
|
||||
self.scope: tuple[str, ...] = scope
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
self._events: EventLog[ProtocolEvent] = EventLog()
|
||||
self._events._bind(is_async=is_async)
|
||||
self._transformers: list[StreamTransformer] = []
|
||||
self._channels: list[StreamChannel[Any]] = []
|
||||
self._logs: list[EventLog[Any]] = []
|
||||
self._seq = 0
|
||||
|
||||
self.extensions: dict[str, Any] = {}
|
||||
self.native_keys: set[str] = set()
|
||||
self._projection_owners: dict[str, str] = {}
|
||||
self._transformer_by_key: dict[str, StreamTransformer] = {}
|
||||
|
||||
if factories is not None:
|
||||
for factory in factories:
|
||||
self._register(factory(scope))
|
||||
else:
|
||||
for transformer in transformers or ():
|
||||
self._register(transformer)
|
||||
|
||||
def make_child(self, scope: tuple[str, ...]) -> StreamMux:
|
||||
"""Build a mini-mux with the same factories scoped to `scope`.
|
||||
|
||||
Used by `SubgraphTransformer` to attach a fresh transformer
|
||||
pipeline to each discovered subgraph handle. The child mux
|
||||
inherits the current pump binding (so cursors on its projection
|
||||
logs drive the root pump) and carries the same factory list
|
||||
forward to any grandchild subgraphs.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the mux was not built from a factory list
|
||||
(i.e., constructed with `transformers=`). Mini-muxes
|
||||
require factories so each scope gets its own fresh
|
||||
transformer instances.
|
||||
"""
|
||||
if not self._factories:
|
||||
raise RuntimeError(
|
||||
"StreamMux.make_child requires the mux to be constructed "
|
||||
"with factories; pre-built transformers can't be cloned "
|
||||
"to a new scope."
|
||||
)
|
||||
child = StreamMux(
|
||||
factories=self._factories,
|
||||
is_async=self._is_async,
|
||||
scope=scope,
|
||||
)
|
||||
# Mini-muxes are created during the pump, after the first
|
||||
# event at the child's scope has already been dispatched.
|
||||
# Consumers reach child projections via the parent's
|
||||
# `subgraphs` handle — necessarily after that first event.
|
||||
# Flip retain on every log and channel so pushes are buffered
|
||||
# until the consumer subscribes.
|
||||
child._events._retain = True
|
||||
for value in child.extensions.values():
|
||||
if isinstance(value, EventLog):
|
||||
value._retain = True
|
||||
elif isinstance(value, StreamChannel):
|
||||
value._log._retain = True
|
||||
if self._pump_fn is not None:
|
||||
child.bind_pump(self._pump_fn)
|
||||
if self._apump_fn is not None:
|
||||
child.bind_apump(self._apump_fn)
|
||||
return child
|
||||
|
||||
def bind_pump(self, fn: Callable[[], bool]) -> None:
|
||||
"""Wire the sync pull callback onto every EventLog in the mux.
|
||||
|
||||
Also propagates to transformers that expose `_bind_pump` so
|
||||
nested handles (e.g., `ChatModelStream` instances produced by
|
||||
`MessagesTransformer`) can drive the graph pump from their
|
||||
projection cursors.
|
||||
"""
|
||||
self._pump_fn = fn
|
||||
self._events._request_more = fn
|
||||
for value in self.extensions.values():
|
||||
if isinstance(value, EventLog):
|
||||
value._request_more = fn
|
||||
elif isinstance(value, StreamChannel):
|
||||
value._log._request_more = fn
|
||||
for transformer in self._transformers:
|
||||
bind = getattr(transformer, "_bind_pump", None)
|
||||
if bind is not None:
|
||||
bind(fn)
|
||||
|
||||
def bind_apump(self, fn: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Async counterpart to `bind_pump`."""
|
||||
self._apump_fn = fn
|
||||
self._events._arequest_more = fn
|
||||
for value in self.extensions.values():
|
||||
if isinstance(value, EventLog):
|
||||
value._arequest_more = fn
|
||||
elif isinstance(value, StreamChannel):
|
||||
value._log._arequest_more = fn
|
||||
for transformer in self._transformers:
|
||||
abind = getattr(transformer, "_bind_apump", None)
|
||||
if abind is not None:
|
||||
abind(fn)
|
||||
|
||||
def _register(self, transformer: StreamTransformer) -> None:
|
||||
"""Register a single transformer.
|
||||
|
||||
Calls `transformer.init()`, stores the transformer for event
|
||||
processing, binds any EventLog or StreamChannel instances in
|
||||
the projection, and merges the projection into `extensions`.
|
||||
"""
|
||||
if transformer_requires_async(transformer) and not self._is_async:
|
||||
raise RuntimeError(
|
||||
f"{type(transformer).__name__} requires an async run — "
|
||||
"it overrides aprocess/afinalize/afail or sets "
|
||||
"requires_async=True. Use astream(), not stream()."
|
||||
)
|
||||
projection = transformer.init()
|
||||
if not isinstance(projection, dict):
|
||||
raise TypeError(
|
||||
f"StreamTransformer.init() must return a dict, "
|
||||
f"got {type(projection).__name__}"
|
||||
)
|
||||
conflicts = set(projection) & set(self.extensions)
|
||||
if conflicts:
|
||||
attributions = ", ".join(
|
||||
f"{key!r} (owned by {self._projection_owners[key]})"
|
||||
for key in sorted(conflicts)
|
||||
)
|
||||
raise ValueError(
|
||||
f"Transformer {type(transformer).__name__} returned "
|
||||
f"projection keys that conflict with already-registered "
|
||||
f"keys: {attributions}"
|
||||
)
|
||||
self._transformers.append(transformer)
|
||||
is_native = bool(getattr(transformer, "_native", False))
|
||||
self._bind_and_wire(projection, is_native=is_native)
|
||||
self.extensions.update(projection)
|
||||
owner_name = type(transformer).__name__
|
||||
for key in projection:
|
||||
self._projection_owners[key] = owner_name
|
||||
self._transformer_by_key[key] = transformer
|
||||
if is_native:
|
||||
self.native_keys.update(projection.keys())
|
||||
on_register = getattr(transformer, "_on_register", None)
|
||||
if on_register is not None:
|
||||
on_register(self)
|
||||
|
||||
def transformer_by_key(self, key: str) -> StreamTransformer | None:
|
||||
"""Return the transformer that owns the projection at `key`, if any."""
|
||||
return self._transformer_by_key.get(key)
|
||||
|
||||
def push(self, event: ProtocolEvent) -> None:
|
||||
"""Push an event through the transformer pipeline and into the log.
|
||||
"""Route an event through all transformers, then append to the main 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).
|
||||
Each transformer's `process()` is called in registration order
|
||||
— except when the transformer has `scope_exact = True` (the
|
||||
default) and the event's namespace differs from the mux's
|
||||
`scope`, in which case the transformer is skipped. Transformers
|
||||
that need to see cross-scope events opt out by setting
|
||||
`scope_exact = False` (e.g. `SubgraphTransformer`).
|
||||
|
||||
If any transformer returns False, the event is suppressed from
|
||||
the main log, but transformers that already saw it keep their
|
||||
side effects.
|
||||
|
||||
Seq is assigned right before an event enters the main log, not
|
||||
before the transformer pipeline runs. This ensures that events
|
||||
auto-forwarded from StreamChannels during `process()` get
|
||||
earlier seq numbers than the original event, preserving
|
||||
monotonic ordering in the log.
|
||||
|
||||
Args:
|
||||
event: The protocol event to dispatch.
|
||||
"""
|
||||
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
|
||||
|
||||
# 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
|
||||
ns = tuple(event["params"]["namespace"])
|
||||
in_scope = ns == self.scope
|
||||
keep = True
|
||||
for transformer in self._transformers:
|
||||
result = transformer.process(event)
|
||||
if result is False:
|
||||
if transformer.scope_exact and not in_scope:
|
||||
continue
|
||||
if not transformer.process(event):
|
||||
keep = False
|
||||
self._current_namespace = []
|
||||
|
||||
# Append to main log if not suppressed
|
||||
if keep:
|
||||
self._event_log.append(event)
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
|
||||
def close(self, output: Any = None) -> None:
|
||||
"""Close the mux and finalize all transformers."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
def close(self) -> None:
|
||||
"""Finalize all transformers, close all projections and the main log.
|
||||
|
||||
EventLogs and StreamChannels discovered in transformer
|
||||
projections are auto-closed after `finalize()` runs —
|
||||
transformers don't need to close them manually. If any
|
||||
transformer's `finalize()` raises, the remaining transformers,
|
||||
projections, and the main log are still closed; the first error
|
||||
is re-raised after cleanup completes.
|
||||
|
||||
Raises:
|
||||
BaseException: The first error raised by a transformer's
|
||||
`finalize()`, re-raised after cleanup finishes.
|
||||
"""
|
||||
first_error: BaseException | None = None
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
transformer.finalize()
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
for log in self._logs:
|
||||
if not log._closed:
|
||||
log.close()
|
||||
for ch in self._channels:
|
||||
if not ch._log._closed:
|
||||
ch._close()
|
||||
self._events.close()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Fail all transformers, projections, and the main log.
|
||||
|
||||
EventLogs and StreamChannels discovered in transformer
|
||||
projections are auto-failed — transformers don't need to fail
|
||||
them manually. If any transformer's `fail()` raises, the
|
||||
remaining transformers, projections, and the main log are still
|
||||
failed.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
transformer.fail(err)
|
||||
except BaseException:
|
||||
pass
|
||||
for log in self._logs:
|
||||
if not log._closed:
|
||||
log.fail(err)
|
||||
for ch in self._channels:
|
||||
if not ch._log._closed:
|
||||
ch._fail(err)
|
||||
self._events.fail(err)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def apush(self, event: ProtocolEvent) -> None:
|
||||
"""Dispatch an event on the async lane.
|
||||
|
||||
Awaits each transformer's `aprocess` in registration order
|
||||
before appending to the main log — except when the transformer
|
||||
has `scope_exact = True` and the event's namespace differs from
|
||||
`self.scope`, in which case it is skipped. A slow `aprocess`
|
||||
serializes the pipeline by design — that's the guarantee that
|
||||
lets a later transformer (or a synchronous consumer) see the
|
||||
result of the async work. For decoupled work, use `schedule()`
|
||||
from inside `process` / `aprocess` instead.
|
||||
|
||||
The main log append is a non-blocking `push` — matching v1's
|
||||
`put_nowait` shape. Memory is bounded by caller pace via the
|
||||
caller-driven pump; see `EventLog` for the full tradeoff story.
|
||||
|
||||
Args:
|
||||
event: The protocol event to dispatch.
|
||||
"""
|
||||
ns = tuple(event["params"]["namespace"])
|
||||
in_scope = ns == self.scope
|
||||
keep = True
|
||||
for transformer in self._transformers:
|
||||
if transformer.scope_exact and not in_scope:
|
||||
continue
|
||||
if not await transformer.aprocess(event):
|
||||
keep = False
|
||||
if keep:
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Finalize on the async lane.
|
||||
|
||||
Awaits every task started via `StreamTransformer.schedule()`
|
||||
across all transformers, then calls `afinalize()` on each,
|
||||
then auto-closes logs, channels, and the main event log.
|
||||
|
||||
If any scheduled task raised under `on_error="raise"`, or any
|
||||
transformer's `afinalize` raises, the exception propagates.
|
||||
The caller (the pump) handles it by routing into `afail`.
|
||||
|
||||
Raises:
|
||||
BaseException: The first scheduled-task or `afinalize`
|
||||
error, re-raised after cleanup.
|
||||
"""
|
||||
pending = self._collect_scheduled_tasks()
|
||||
if pending:
|
||||
results = await asyncio.gather(*pending, return_exceptions=True)
|
||||
first_err = next(
|
||||
(
|
||||
r
|
||||
for r in results
|
||||
if isinstance(r, BaseException)
|
||||
and not isinstance(r, asyncio.CancelledError)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if first_err is not None:
|
||||
raise first_err
|
||||
|
||||
first_error: BaseException | None = None
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
await transformer.afinalize()
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
for log in self._logs:
|
||||
if not log._closed:
|
||||
log.close()
|
||||
for ch in self._channels:
|
||||
if not ch._log._closed:
|
||||
ch._close()
|
||||
self._events.close()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
async def afail(self, err: BaseException) -> None:
|
||||
"""Fail on the async lane.
|
||||
|
||||
Cancels every scheduled task across all transformers, awaits
|
||||
them to completion, then runs each transformer's `afail` hook
|
||||
and auto-fails logs, channels, and the main event log.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
pending = self._collect_scheduled_tasks()
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
for transformer in self._transformers:
|
||||
transformer.finalize()
|
||||
try:
|
||||
await transformer.afail(err)
|
||||
except BaseException:
|
||||
pass
|
||||
for log in self._logs:
|
||||
if not log._closed:
|
||||
log.fail(err)
|
||||
for ch in self._channels:
|
||||
if not ch._log._closed:
|
||||
ch._fail(err)
|
||||
if not self._events._closed:
|
||||
self._events.fail(err)
|
||||
|
||||
def fail(self, error: BaseException) -> None:
|
||||
"""Fail the mux and propagate the error to all consumers."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._error = error
|
||||
def _collect_scheduled_tasks(self) -> list[asyncio.Task[Any]]:
|
||||
"""Return a snapshot of in-flight tasks scheduled via transformers."""
|
||||
return [
|
||||
task
|
||||
for transformer in self._transformers
|
||||
for task in getattr(transformer, "_stream_scheduled_tasks", ())
|
||||
if not task.done()
|
||||
]
|
||||
|
||||
for transformer in self._transformers:
|
||||
transformer.fail(error)
|
||||
# ------------------------------------------------------------------
|
||||
# Binding and StreamChannel auto-wiring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# -- Inspection ---------------------------------------------------------
|
||||
def _bind_and_wire(
|
||||
self, projection: dict[str, Any], *, is_native: bool = False
|
||||
) -> None:
|
||||
"""Bind and wire EventLog / StreamChannel instances in a projection.
|
||||
|
||||
@property
|
||||
def interrupted(self) -> bool:
|
||||
return self._interrupted
|
||||
|
||||
@property
|
||||
def interrupts(self) -> list[InterruptPayload]:
|
||||
return list(self._interrupts)
|
||||
|
||||
@property
|
||||
def event_log(self) -> list[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 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.
|
||||
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.
|
||||
|
||||
No namespace filtering is applied — all buffered events are
|
||||
replayed. Transformers that need namespace filtering should do
|
||||
so inside their ``process()`` implementation.
|
||||
`is_native` controls wire naming: native transformer channels
|
||||
emit events with `method` equal to the channel name, while
|
||||
non-native channels get a `custom:` prefix to keep user-defined
|
||||
projections from colliding with built-in method names.
|
||||
"""
|
||||
snapshot = len(self._event_log)
|
||||
self._transformers.append(transformer)
|
||||
for i in range(snapshot):
|
||||
transformer.process(self._event_log[i])
|
||||
if self._closed:
|
||||
transformer.finalize()
|
||||
for value in projection.values():
|
||||
if isinstance(value, StreamChannel):
|
||||
value._bind(is_async=self._is_async)
|
||||
self._channels.append(value)
|
||||
channel_name = value.name
|
||||
|
||||
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
|
||||
def _make_forwarder(ch: StreamChannel[Any]) -> Any:
|
||||
def _make_forward(name: str, native: bool) -> Callable[[Any], None]:
|
||||
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),
|
||||
"data": item,
|
||||
},
|
||||
)
|
||||
)
|
||||
self._next_emit_seq += 1
|
||||
self._forward(name, item, native=native)
|
||||
|
||||
return _forward
|
||||
|
||||
channel._wire(_make_forwarder(channel))
|
||||
value._wire(_make_forward(channel_name, is_native))
|
||||
elif isinstance(value, EventLog):
|
||||
value._bind(is_async=self._is_async)
|
||||
self._logs.append(value)
|
||||
|
||||
def _forward(self, channel_name: str, item: Any, *, native: bool) -> None:
|
||||
"""Inject a ProtocolEvent for a StreamChannel push.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AsyncStreamMux — async consumer APIs on top of the sync core
|
||||
# ---------------------------------------------------------------------------
|
||||
Forwarded events bypass the transformer pipeline to avoid
|
||||
infinite recursion (a transformer that pushes to a channel
|
||||
during `process()` would re-trigger itself). These events are
|
||||
visible in the main event log but are not passed through
|
||||
transformers' `process()` methods.
|
||||
|
||||
|
||||
class AsyncStreamMux(StreamMux):
|
||||
"""Async extension of :class:`StreamMux`.
|
||||
|
||||
Adds output futures, async event subscriptions, and subgraph
|
||||
discovery on top of the synchronous producer/transformer core.
|
||||
"""
|
||||
|
||||
def __init__(self, transformers: list[StreamTransformer] | None = None) -> None:
|
||||
super().__init__(transformers=transformers)
|
||||
# Notification event — set on every push/close/fail to wake async consumers
|
||||
self._notify: asyncio.Event = asyncio.Event()
|
||||
# Waiters for new namespace discovery
|
||||
self._ns_waiters: list[asyncio.Future[None]] = []
|
||||
# Output promise tracking
|
||||
self._output_futures: dict[str, asyncio.Future[Any]] = {}
|
||||
|
||||
# -- Producer overrides (extend to resolve async primitives) -------------
|
||||
|
||||
def push(self, event: ProtocolEvent) -> None:
|
||||
# Peek at namespace before super().push() so we can detect new
|
||||
# discoveries and wake waiters.
|
||||
ns = event["params"].get("namespace", [])
|
||||
is_new_ns = bool(ns) and ns[0] not in self._discovered_ns
|
||||
super().push(event)
|
||||
if is_new_ns and ns[0] in self._discovered_ns:
|
||||
self._wake_ns_waiters()
|
||||
self._notify.set()
|
||||
|
||||
def close(self, output: Any = None) -> None:
|
||||
super().close(output)
|
||||
self._notify.set()
|
||||
# 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:
|
||||
super().fail(error)
|
||||
self._notify.set()
|
||||
# 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()
|
||||
|
||||
# -- Async consumer API -------------------------------------------------
|
||||
|
||||
async def subscribe_events(
|
||||
self, path: list[str] | None = None, offset: int = 0
|
||||
) -> AsyncIterator[ProtocolEvent]:
|
||||
"""Async iterate over events matching *path*.
|
||||
|
||||
If *path* is ``None`` or empty, all events are yielded.
|
||||
Otherwise, only events whose namespace starts with *path*
|
||||
are yielded.
|
||||
|
||||
Uses the list + ``asyncio.Event`` notification pattern: poll
|
||||
the event log, yield what's new, await the notify event for more.
|
||||
Native transformers emit with `method` equal to the channel
|
||||
name (e.g. `"lifecycle"`); non-native transformers get a
|
||||
`custom:` prefix so user-defined projections can't collide
|
||||
with built-in method names.
|
||||
"""
|
||||
cursor = offset
|
||||
while True:
|
||||
while cursor < len(self._event_log):
|
||||
event = self._event_log[cursor]
|
||||
cursor += 1
|
||||
if not path or _ns_starts_with(
|
||||
event["params"].get("namespace", []), path
|
||||
):
|
||||
yield event
|
||||
if self._closed:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return
|
||||
self._notify.clear()
|
||||
await self._notify.wait()
|
||||
|
||||
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 _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()
|
||||
|
||||
|
||||
|
||||
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"]
|
||||
self._seq += 1
|
||||
method = channel_name if native else f"custom:{channel_name}"
|
||||
event: ProtocolEvent = {
|
||||
"type": "event",
|
||||
"seq": self._seq,
|
||||
"method": method,
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": item,
|
||||
},
|
||||
}
|
||||
self._events.push(event)
|
||||
|
||||
@@ -1,166 +1,303 @@
|
||||
"""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
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ProtocolEventParams(TypedDict):
|
||||
"""Payload envelope for a :class:`ProtocolEvent`."""
|
||||
"""Parameters for a protocol event.
|
||||
|
||||
namespace: Namespace
|
||||
node: NotRequired[str]
|
||||
`timestamp` is wall-clock milliseconds since the epoch and can go
|
||||
backwards across NTP adjustments — use `ProtocolEvent.seq` for
|
||||
ordering.
|
||||
"""
|
||||
|
||||
namespace: list[str]
|
||||
timestamp: int
|
||||
data: Any
|
||||
interrupts: NotRequired[tuple[Any, ...]]
|
||||
|
||||
|
||||
class ProtocolEvent(TypedDict):
|
||||
"""A single protocol event emitted by the StreamingHandler infrastructure.
|
||||
"""A protocol event emitted by the streaming infrastructure.
|
||||
|
||||
``method`` corresponds to a
|
||||
:pydata:`~langgraph.types.StreamMode` value (``"messages"``,
|
||||
``"updates"``, etc.).
|
||||
Wraps a raw stream part (values, messages, custom, etc.) in a uniform
|
||||
envelope with a monotonic sequence number assigned by the StreamMux.
|
||||
Consumers that need a total order across events should use `seq`, not
|
||||
`params.timestamp` (which is wall-clock and not monotonic).
|
||||
"""
|
||||
|
||||
type: str # always "event"
|
||||
seq: NotRequired[int] # assigned by StreamMux.push(); absent before push()
|
||||
method: str # StreamMode value
|
||||
type: Literal["event"]
|
||||
eventId: NotRequired[str]
|
||||
seq: NotRequired[int]
|
||||
method: str # StreamMode value: "values", "messages", "custom", etc.
|
||||
params: _ProtocolEventParams
|
||||
|
||||
|
||||
class StreamTransformer(ABC):
|
||||
"""Extension point for custom stream projections.
|
||||
|
||||
Implementations are registered with ``StreamingHandler`` and receive every
|
||||
:class:`ProtocolEvent` before it is appended to the event log.
|
||||
Transformers observe protocol events flowing through the StreamMux and
|
||||
build typed derived projections (EventLogs, StreamChannels, promises,
|
||||
etc.).
|
||||
|
||||
Any :class:`~langgraph.stream.stream_channel.StreamChannel` instances
|
||||
returned by ``init()`` are automatically wired to the protocol event
|
||||
stream by the mux.
|
||||
Set `_native = True` on a transformer to have its projection keys
|
||||
exposed as direct attributes on the run stream (in addition to
|
||||
appearing in `run.extensions`).
|
||||
|
||||
Subclasses must implement `init` and override at least one of
|
||||
`process` / `aprocess`. The `finalize` / `afinalize` and `fail` /
|
||||
`afail` hooks are optional — the default implementations are no-ops.
|
||||
EventLog and StreamChannel instances in the projection dict are
|
||||
auto-closed / auto-failed by the mux, so most transformers don't
|
||||
need `finalize` or `fail` at all.
|
||||
|
||||
Transformers that need async work pick the async lane by:
|
||||
|
||||
1. Overriding `aprocess` (and optionally `afinalize` / `afail`), or
|
||||
2. Calling `self.schedule(coro)` from inside a sync `process`, or
|
||||
3. Setting `requires_async = True` explicitly.
|
||||
|
||||
The mux detects these cases at registration and raises if they're
|
||||
used under sync `stream()` — they only work under `astream()`.
|
||||
|
||||
Use `aprocess` when the pump must wait for async work before the
|
||||
next transformer sees the event (e.g. PII redaction that mutates
|
||||
`event` in place). Use `schedule()` for decoupled async work whose
|
||||
result lands on an independent projection (e.g. async moderation
|
||||
scoring, cost lookup, external tracing).
|
||||
|
||||
Attributes:
|
||||
scope: Namespace the transformer operates within — `()` for the
|
||||
root mux, a subgraph's namespace tuple inside a mini-mux.
|
||||
Set at construction from the mux's scope (each factory is
|
||||
called as `factory(scope)`). Transformers that only care
|
||||
about events at their own namespace compare against
|
||||
`self.scope`; subgraph-aware transformers can treat it as
|
||||
a parent path.
|
||||
scope_exact: If True (the default), the mux only calls
|
||||
`process` / `aprocess` for events whose namespace equals
|
||||
`self.scope` — user transformers get scope-scoped events
|
||||
for free with no boilerplate. Set False for transformers
|
||||
that need to see events across scopes (e.g.
|
||||
`SubgraphTransformer` forwards deeper events into child
|
||||
mini-muxes).
|
||||
requires_async: Explicit opt-in for transformers that need a
|
||||
running event loop but don't override any async method (for
|
||||
example, transformers that call `schedule()` from a sync
|
||||
`process`). The mux also auto-detects the async lane when
|
||||
`aprocess`, `afinalize`, or `afail` is overridden.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def init(self) -> Any:
|
||||
"""Return the initial projection value.
|
||||
requires_async: ClassVar[bool] = False
|
||||
scope_exact: ClassVar[bool] = True
|
||||
|
||||
Called once before the run. Any
|
||||
:class:`~langgraph.stream.stream_channel.StreamChannel` instances
|
||||
in the return value are automatically wired by the mux.
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
"""Initialize the transformer with its mux's scope.
|
||||
|
||||
Args:
|
||||
scope: The namespace tuple the owning mux is scoped to.
|
||||
`()` for the root, the subgraph's namespace inside a
|
||||
mini-mux. Factories receive this at construction time
|
||||
(`factory(scope)` in `StreamMux`).
|
||||
"""
|
||||
self.scope: tuple[str, ...] = scope
|
||||
|
||||
@abstractmethod
|
||||
def init(self) -> dict[str, Any]:
|
||||
"""Return the projection dict.
|
||||
|
||||
Keys become entries in `run.extensions`. If the transformer has
|
||||
`_native = True`, keys are also set as direct attributes on the
|
||||
run stream.
|
||||
|
||||
StreamChannel instances in the return value are automatically
|
||||
wired by the StreamMux for protocol event auto-forwarding.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
"""Process an event.
|
||||
"""Handle an event on the sync lane.
|
||||
|
||||
Return ``True`` to keep the event in the log, ``False`` to suppress
|
||||
it.
|
||||
Called for every event before it is appended to the main event
|
||||
log. Subclasses must override either `process` or `aprocess`.
|
||||
The default raises so a missing override fails loudly rather
|
||||
than silently passing every event through.
|
||||
|
||||
Args:
|
||||
event: The protocol event to observe.
|
||||
|
||||
Returns:
|
||||
True to keep the event in the main log, False to suppress it.
|
||||
"""
|
||||
...
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} must override process() or aprocess()"
|
||||
)
|
||||
|
||||
async def aprocess(self, event: ProtocolEvent) -> bool:
|
||||
"""Handle an event on the async lane.
|
||||
|
||||
The mux awaits this before dispatching to the next transformer,
|
||||
so a slow `aprocess` serializes the pipeline. Use it only when
|
||||
a later transformer — or a consumer reading the event
|
||||
synchronously — must see the result of the async work (e.g.
|
||||
PII redaction that mutates `event` in place).
|
||||
|
||||
The default delegates to `process`, so purely-sync transformers
|
||||
run unchanged under `astream()`.
|
||||
|
||||
Args:
|
||||
event: The protocol event to observe.
|
||||
|
||||
Returns:
|
||||
True to keep the event in the main log, False to suppress it.
|
||||
"""
|
||||
return self.process(event)
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Called once when the run completes successfully.
|
||||
"""Called when the run ends normally (sync lane).
|
||||
|
||||
Optional — the mux auto-closes any :class:`StreamChannel` instances,
|
||||
so transformers that only use channels can omit this.
|
||||
Override to close EventLogs, resolve promises, or perform other
|
||||
teardown. StreamChannel instances are auto-closed by the mux.
|
||||
"""
|
||||
|
||||
async def afinalize(self) -> None:
|
||||
"""Called when the run ends normally (async lane).
|
||||
|
||||
By the time this runs, the mux has already awaited every task
|
||||
started via `schedule()`, so EventLogs can be closed here
|
||||
without a last-task-wins race.
|
||||
|
||||
The default delegates to `finalize`.
|
||||
"""
|
||||
self.finalize()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Called once when the run fails.
|
||||
"""Called when the run ends with an error (sync lane).
|
||||
|
||||
Optional — the mux auto-fails any :class:`StreamChannel` instances,
|
||||
so transformers that only use channels can omit this.
|
||||
Override to fail EventLogs, reject promises, or perform other
|
||||
teardown. StreamChannel instances are auto-failed by the mux.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
|
||||
async def afail(self, err: BaseException) -> None:
|
||||
"""Called when the run ends with an error (async lane).
|
||||
|
||||
class InterruptPayload(TypedDict):
|
||||
"""An interrupt produced during a StreamingHandler run."""
|
||||
The mux cancels and awaits every task started via `schedule()`
|
||||
before calling this, so cleanup doesn't race with in-flight work.
|
||||
|
||||
interrupt_id: str
|
||||
payload: Any
|
||||
The default delegates to `fail`.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
self.fail(err)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scheduled async work
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def schedule(
|
||||
self,
|
||||
coro: Coroutine[Any, Any, Any],
|
||||
*,
|
||||
on_error: Literal["log", "raise"] = "log",
|
||||
) -> asyncio.Task[Any]:
|
||||
"""Schedule a coroutine tied to this transformer's lifecycle.
|
||||
|
||||
The mux holds the task reference, awaits all scheduled tasks
|
||||
during `aclose()` before calling `afinalize()`, and cancels
|
||||
them on `afail()`. Authors don't need to track tasks or
|
||||
implement the last-task-closes-the-log dance.
|
||||
|
||||
Requires a running event loop — call only under `astream()`.
|
||||
Set `requires_async = True` on the class so registration under
|
||||
sync `stream()` fails fast with a clear message.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run. Its lifecycle is owned by the
|
||||
mux from this point on.
|
||||
on_error: `"log"` (default) catches and logs any exception
|
||||
the coroutine raises, so a single failure doesn't tear
|
||||
down the run. `"raise"` lets the exception propagate
|
||||
when the mux joins pendings, converting the close path
|
||||
into the fail path.
|
||||
|
||||
Returns:
|
||||
The asyncio Task. Authors rarely need to await it directly
|
||||
— consumers read results from whatever projection the
|
||||
coroutine pushes into.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called without a running event loop (i.e.
|
||||
under sync `stream()` rather than `astream()`).
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
raise RuntimeError(
|
||||
f"{type(self).__name__}.schedule() requires a running "
|
||||
"event loop; this transformer must run under astream(), "
|
||||
"not stream(). Set requires_async=True on the class so "
|
||||
"this fails at registration rather than at first event."
|
||||
) from None
|
||||
|
||||
wrapped = self._wrap_scheduled(coro) if on_error == "log" else coro
|
||||
task = asyncio.create_task(wrapped)
|
||||
tasks = self._scheduled_task_set()
|
||||
tasks.add(task)
|
||||
task.add_done_callback(tasks.discard)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
async def _wrap_scheduled(coro: Coroutine[Any, Any, Any]) -> Any:
|
||||
try:
|
||||
return await coro
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException:
|
||||
_logger.exception("Scheduled StreamTransformer task failed")
|
||||
|
||||
def _scheduled_task_set(self) -> set[asyncio.Task[Any]]:
|
||||
"""Return the lazily-allocated task set.
|
||||
|
||||
Avoids requiring subclasses to call `super().__init__()`.
|
||||
"""
|
||||
tasks: set[asyncio.Task[Any]] | None = getattr(
|
||||
self, "_stream_scheduled_tasks", None
|
||||
)
|
||||
if tasks is None:
|
||||
tasks = set()
|
||||
self._stream_scheduled_tasks = tasks
|
||||
return tasks
|
||||
|
||||
|
||||
__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",
|
||||
]
|
||||
def transformer_requires_async(transformer: StreamTransformer) -> bool:
|
||||
"""Return True if the transformer needs a running event loop.
|
||||
|
||||
A transformer requires async if it explicitly opts in
|
||||
(`requires_async = True`) or overrides any of the async-lane methods
|
||||
(`aprocess`, `afinalize`, `afail`).
|
||||
|
||||
Args:
|
||||
transformer: The transformer to inspect.
|
||||
|
||||
Returns:
|
||||
True if the transformer cannot run under sync `stream()`.
|
||||
"""
|
||||
if transformer.requires_async:
|
||||
return True
|
||||
cls = type(transformer)
|
||||
for name in ("aprocess", "afinalize", "afail"):
|
||||
if getattr(cls, name) is not getattr(StreamTransformer, name):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"""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 Generator
|
||||
from typing import Any
|
||||
|
||||
from langgraph.stream._types import UsageInfo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# -- Public projections ------------------------------------------------
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Accumulated text content."""
|
||||
return self._text_acc
|
||||
|
||||
@property
|
||||
def reasoning(self) -> str:
|
||||
"""Accumulated reasoning content."""
|
||||
return self._reasoning_acc
|
||||
|
||||
@property
|
||||
def usage(self) -> UsageInfo | None:
|
||||
"""Usage info, available after the message finishes."""
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dual-projection helpers — sync data container + async notification layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DualProjection:
|
||||
"""Sync data container for incremental deltas and a final value.
|
||||
|
||||
Stores deltas as they arrive and tracks the final accumulated value.
|
||||
No async primitives — see :class:`_AsyncDualProjection` for the
|
||||
async-iterable + awaitable extension.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._deltas: list[Any] = []
|
||||
self._done = False
|
||||
self._error: BaseException | None = 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)
|
||||
|
||||
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
|
||||
|
||||
def _fail(self, error: BaseException) -> None:
|
||||
self._error = error
|
||||
self._done = True
|
||||
|
||||
|
||||
class _AsyncDualProjection(_DualProjection):
|
||||
"""Async extension of :class:`_DualProjection`.
|
||||
|
||||
Async iterable of deltas that is also awaitable for the final value.
|
||||
Uses an ``asyncio.Event`` to notify async consumers when new data
|
||||
arrives — the same pattern as :class:`AsyncStreamMux`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._notify: asyncio.Event = asyncio.Event()
|
||||
|
||||
# -- Producer overrides (extend to notify) -----------------------------
|
||||
|
||||
def _push(self, delta: Any) -> None:
|
||||
super()._push(delta)
|
||||
self._notify.set()
|
||||
|
||||
def _finish(self, accumulated: Any) -> None:
|
||||
super()._finish(accumulated)
|
||||
self._notify.set()
|
||||
|
||||
def _fail(self, error: BaseException) -> None:
|
||||
super()._fail(error)
|
||||
self._notify.set()
|
||||
|
||||
# -- Async iterable (yields deltas) ------------------------------------
|
||||
|
||||
def __aiter__(self) -> _AsyncDualProjectionIterator:
|
||||
return _AsyncDualProjectionIterator(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
|
||||
self._notify.clear()
|
||||
await self._notify.wait()
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._final_value
|
||||
|
||||
|
||||
class _AsyncDualProjectionIterator:
|
||||
"""Async iterator over an :class:`_AsyncDualProjection`'s deltas."""
|
||||
|
||||
__slots__ = ("_proj", "_offset")
|
||||
|
||||
def __init__(self, proj: _AsyncDualProjection) -> None:
|
||||
self._proj = proj
|
||||
self._offset = 0
|
||||
|
||||
def __aiter__(self) -> _AsyncDualProjectionIterator:
|
||||
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
|
||||
self._proj._notify.clear()
|
||||
await self._proj._notify.wait()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 = _AsyncDualProjection()
|
||||
self._reasoning_proj = _AsyncDualProjection()
|
||||
self._usage_proj = _AsyncDualProjection()
|
||||
|
||||
# -- Public projections (override sync properties) ---------------------
|
||||
|
||||
@property
|
||||
def text(self) -> _AsyncDualProjection:
|
||||
"""Text content — async iterable of deltas, awaitable for full text."""
|
||||
return self._text_proj
|
||||
|
||||
@property
|
||||
def reasoning(self) -> _AsyncDualProjection:
|
||||
"""Reasoning content — async iterable of deltas, awaitable for full text."""
|
||||
return self._reasoning_proj
|
||||
|
||||
@property
|
||||
def usage(self) -> _AsyncDualProjection:
|
||||
"""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"]
|
||||
@@ -1,586 +1,408 @@
|
||||
"""GraphRunStream and AsyncGraphRunStream for StreamingHandler.
|
||||
|
||||
These are the top-level objects returned by
|
||||
``StreamingHandler.stream()`` / ``StreamingHandler.astream()``.
|
||||
|
||||
``AsyncGraphRunStream`` wraps an :class:`AsyncStreamMux` and exposes
|
||||
``.values``, ``.messages``, ``.subgraphs``, ``.output``, and
|
||||
``.messages_from()``.
|
||||
|
||||
``GraphRunStream`` wraps a :class:`StreamMux` and exposes the sync
|
||||
equivalents: ``.values``, ``.messages``, and ``.output``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from typing import Any
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
||||
from types import MappingProxyType, TracebackType
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langgraph.stream._convert import convert_to_protocol_event
|
||||
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
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Values projection — dual async-iterable + awaitable
|
||||
# ---------------------------------------------------------------------------
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.stream.transformers import ValuesTransformer
|
||||
|
||||
|
||||
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
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[Any]:
|
||||
log = self._values_transformer.values_log
|
||||
cursor = 0
|
||||
while True:
|
||||
while cursor < len(log):
|
||||
item = log[cursor]
|
||||
cursor += 1
|
||||
if item.get("namespace", []) == self._ns:
|
||||
data = item["data"]
|
||||
if data is not None and self._mapper is not None:
|
||||
yield self._mapper(data)
|
||||
else:
|
||||
yield data
|
||||
if self._mux._closed:
|
||||
return
|
||||
self._mux._notify.clear()
|
||||
await self._mux._notify.wait()
|
||||
|
||||
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
|
||||
def _drive_until_done(pump: Callable[[], bool]) -> None:
|
||||
"""Call the sync pump until it returns False."""
|
||||
while pump():
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Messages projection
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _adrive_until_done(pump: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Call the async pump until it returns False."""
|
||||
while await pump():
|
||||
pass
|
||||
|
||||
|
||||
class _MessagesProjection:
|
||||
"""Async iterable of :class:`AsyncChatModelStream` instances."""
|
||||
class BaseRunStream:
|
||||
"""Shared shape for any object that wraps a `StreamMux`.
|
||||
|
||||
def __init__(self, mux: AsyncStreamMux, messages_transformer: MessagesTransformer) -> None:
|
||||
self._mux = mux
|
||||
self._transformer = messages_transformer
|
||||
Root (`GraphRunStream` / `AsyncGraphRunStream`) and scoped
|
||||
(`SubgraphRunStream`) streams both compose a `StreamMux`. The mux
|
||||
owns the projections — `values`, `messages`, `subgraphs`, and any
|
||||
user-registered keys — all exposed via `extensions`. Native
|
||||
projections (`_native = True`) are also bound as direct attributes
|
||||
(`run.values`, `run.messages`, …) for ergonomics.
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[AsyncChatModelStream]:
|
||||
log = self._transformer.messages_log
|
||||
cursor = 0
|
||||
while True:
|
||||
while cursor < len(log):
|
||||
yield log[cursor]
|
||||
cursor += 1
|
||||
if self._mux._closed:
|
||||
return
|
||||
self._mux._notify.clear()
|
||||
await self._mux._notify.wait()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
Raw iteration (`for event in run` / `async for event in run`) and
|
||||
the `interleave(...)` helper both live here so every subclass
|
||||
behaves consistently. Subclasses only add pump ownership, scope
|
||||
metadata, or sync/async flavor.
|
||||
"""
|
||||
|
||||
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:
|
||||
def __init__(self, mux: StreamMux) -> 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 --------------------------------------------------
|
||||
self.extensions: Mapping[str, Any] = MappingProxyType(mux.extensions)
|
||||
for key in mux.native_keys:
|
||||
setattr(self, key, mux.extensions[key])
|
||||
|
||||
@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)
|
||||
def _values_transformer(self) -> ValuesTransformer:
|
||||
"""Look up the `ValuesTransformer` registered on this mux.
|
||||
|
||||
@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(self._mux, 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(self._mux, 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 a list so that iteration drives the sync pump.
|
||||
|
||||
Used by all :class:`GraphRunStream` projections (``__iter__``,
|
||||
``.values``, ``.messages``, ``.extensions``) so that iterating
|
||||
any projection lazily consumes the source.
|
||||
"""
|
||||
|
||||
__slots__ = ("_log", "_pump_one")
|
||||
|
||||
def __init__(self, log: list, 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).
|
||||
`output` / `interrupted` / `interrupts` need scalar state from
|
||||
the `ValuesTransformer` without threading it through the
|
||||
constructor. Raises if none is registered — `stream_v2` /
|
||||
`astream_v2` always register one, so hitting this path means
|
||||
the caller assembled the mux themselves and forgot.
|
||||
"""
|
||||
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
|
||||
from langgraph.stream.transformers import ValuesTransformer
|
||||
|
||||
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) -----------------------------------------
|
||||
for t in self._mux._transformers:
|
||||
if isinstance(t, ValuesTransformer):
|
||||
return t
|
||||
raise RuntimeError(
|
||||
"No ValuesTransformer is registered on this mux — "
|
||||
"`output` / `interrupted` / `interrupts` are unavailable."
|
||||
)
|
||||
|
||||
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
|
||||
"""Sync iteration of protocol events on this mux's main log.
|
||||
|
||||
# -- 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 yielded ``ChatModelStream`` is fully populated (``done=True``)
|
||||
so that sync consumers can read ``.text``, ``.reasoning``, and
|
||||
``.usage`` immediately.
|
||||
Raises at the EventLog level if the mux is async-bound.
|
||||
"""
|
||||
t = self._find_transformer("messages")
|
||||
if t is None:
|
||||
return
|
||||
for msg in _PumpDrivenLog(t.value, self._pump_one):
|
||||
# Pump until this message is complete so sync consumers
|
||||
# get a fully populated ChatModelStream.
|
||||
while not msg.done:
|
||||
if not self._pump_one():
|
||||
break
|
||||
yield msg
|
||||
return iter(self._mux._events)
|
||||
|
||||
# -- State --------------------------------------------------------------
|
||||
def __aiter__(self) -> AsyncIterator[ProtocolEvent]:
|
||||
"""Async iteration of protocol events on this mux's main log.
|
||||
|
||||
Raises at the EventLog level if the mux is sync-bound.
|
||||
"""
|
||||
return self._mux._events.__aiter__()
|
||||
|
||||
def interleave(self, *names: str) -> Iterator[tuple[str, Any]]:
|
||||
"""Iterate multiple projections round-robin, yielding ``(name, item)``.
|
||||
|
||||
Each turn advances one projection's cursor; when a cursor's
|
||||
buffer is empty, pulling from it drives the pump once, which
|
||||
fans out to every subscribed projection log. Projections whose
|
||||
items aren't consumed on this turn sit in their own buffers
|
||||
only until the next turn reaches them, bounding memory by the
|
||||
skew between projection rates rather than letting any single
|
||||
log grow to the full run length.
|
||||
|
||||
Projections are exhausted independently; a projection that
|
||||
finishes early drops out of the rotation while others
|
||||
continue. The overall iterator ends once all named projections
|
||||
are done.
|
||||
|
||||
Args:
|
||||
*names: Projection keys to interleave. Must match keys in
|
||||
`extensions`.
|
||||
|
||||
Yields:
|
||||
`(name, item)` tuples in round-robin order across the named
|
||||
projections.
|
||||
|
||||
Raises:
|
||||
KeyError: If a name doesn't match a registered projection.
|
||||
|
||||
Example:
|
||||
```python
|
||||
for name, item in run.interleave("messages", "values"):
|
||||
if name == "messages":
|
||||
print("msg:", item)
|
||||
else:
|
||||
print("val:", item)
|
||||
```
|
||||
"""
|
||||
cursors: dict[str, Iterator[Any]] = {
|
||||
name: iter(self.extensions[name]) for name in names
|
||||
}
|
||||
done: set[str] = set()
|
||||
while len(done) < len(cursors):
|
||||
for name, cursor in cursors.items():
|
||||
if name in done:
|
||||
continue
|
||||
try:
|
||||
item = next(cursor)
|
||||
except StopIteration:
|
||||
done.add(name)
|
||||
continue
|
||||
yield (name, item)
|
||||
|
||||
|
||||
class GraphRunStream(BaseRunStream):
|
||||
"""Sync run stream with caller-driven pumping.
|
||||
|
||||
The caller's iteration on any projection (`values`, `messages`,
|
||||
raw events, or `output`) drives the graph forward. No background
|
||||
thread is used — the caller's `for` loop is the pump.
|
||||
|
||||
Projections are single-consumer — iterating `run.values` twice
|
||||
raises. Use `projection.tee(n)` if you genuinely need fan-out.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
graph_iter: Iterator[Any],
|
||||
mux: StreamMux,
|
||||
) -> None:
|
||||
"""Initialize the run stream.
|
||||
|
||||
Args:
|
||||
graph_iter: Pull-based iterator over the graph's stream.
|
||||
mux: The StreamMux owning projections and the main log.
|
||||
Must have a `ValuesTransformer` registered for
|
||||
`output` / `interrupted` / `interrupts` to work.
|
||||
"""
|
||||
super().__init__(mux)
|
||||
self._graph_iter = graph_iter
|
||||
self._exhausted = False
|
||||
mux.bind_pump(self._pump_next)
|
||||
|
||||
def _pump_next(self) -> bool:
|
||||
"""Pull one event from the graph and push it through the mux.
|
||||
|
||||
Returns:
|
||||
True if an event was pulled, False if the graph is
|
||||
exhausted or has raised.
|
||||
"""
|
||||
if self._exhausted:
|
||||
return False
|
||||
try:
|
||||
part = next(self._graph_iter)
|
||||
except StopIteration:
|
||||
self._mux.close()
|
||||
self._exhausted = True
|
||||
return False
|
||||
except Exception as e:
|
||||
self._mux.fail(e)
|
||||
self._exhausted = True
|
||||
return False
|
||||
self._mux.push(convert_to_protocol_event(part))
|
||||
return True
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Stop the run early.
|
||||
|
||||
Closes the mux and marks the stream exhausted. The graph
|
||||
iterator is dropped; any in-flight nodes see the closure on
|
||||
their next yield point. Idempotent.
|
||||
"""
|
||||
if self._exhausted:
|
||||
return
|
||||
self._exhausted = True
|
||||
try:
|
||||
self._mux.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> GraphRunStream:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.abort()
|
||||
|
||||
@property
|
||||
def output(self) -> dict[str, Any] | None:
|
||||
"""Drive the run to completion and return the final state."""
|
||||
_drive_until_done(self._pump_next)
|
||||
err = self._values_transformer.error
|
||||
if err is not None:
|
||||
raise err
|
||||
return self._values_transformer._latest
|
||||
|
||||
@property
|
||||
def interrupted(self) -> bool:
|
||||
return self._mux.interrupted
|
||||
"""Drive the run to completion, then return whether it was interrupted.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
_drive_until_done(self._pump_next)
|
||||
err = self._values_transformer.error
|
||||
if err is not None:
|
||||
raise err
|
||||
return self._values_transformer._interrupted
|
||||
|
||||
@property
|
||||
def interrupts(self) -> list[InterruptPayload]:
|
||||
return self._mux.interrupts
|
||||
def interrupts(self) -> list[Any]:
|
||||
"""Drive the run to completion, then return interrupt payloads.
|
||||
|
||||
# -- 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, list):
|
||||
result[name] = _PumpDrivenLog(value, self._pump_one)
|
||||
else:
|
||||
result[name] = value
|
||||
return result
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
_drive_until_done(self._pump_next)
|
||||
err = self._values_transformer.error
|
||||
if err is not None:
|
||||
raise err
|
||||
return self._values_transformer._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.
|
||||
class AsyncGraphRunStream(BaseRunStream):
|
||||
"""Async run stream with caller-driven pumping.
|
||||
|
||||
The source iterator is stored on the returned stream and consumed
|
||||
lazily as projections are iterated.
|
||||
Async iteration on any projection drives the graph forward — there
|
||||
is no background task. Concurrent consumers share a single-flight
|
||||
pump via an `asyncio.Lock`, so each awaiting cursor contributes
|
||||
one event per acquisition. Backpressure comes from the logs: when
|
||||
a subscribed log's buffer reaches `maxlen`, `apush` awaits the
|
||||
subscriber to drain, which holds back the pump and paces the
|
||||
graph.
|
||||
|
||||
Built-in transformers (values, messages) are always registered first
|
||||
so that user-supplied transformers see events after built-in
|
||||
processing.
|
||||
Projections are single-consumer — a second `aiter(run.values)`
|
||||
raises. Use `projection.tee(n)` for fan-out.
|
||||
|
||||
Use as an async context manager to guarantee clean shutdown on
|
||||
early exit:
|
||||
|
||||
```python
|
||||
async with await handler.astream(input) as run:
|
||||
async for msg in run.messages:
|
||||
...
|
||||
```
|
||||
"""
|
||||
# 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)
|
||||
def __init__(
|
||||
self,
|
||||
graph_aiter: AsyncIterator[Any],
|
||||
mux: StreamMux,
|
||||
) -> None:
|
||||
"""Initialize the async run stream.
|
||||
|
||||
mux = StreamMux(transformers=all_transformers)
|
||||
Args:
|
||||
graph_aiter: Async iterator over the graph's stream.
|
||||
mux: The StreamMux owning projections and the main log.
|
||||
Must have a `ValuesTransformer` registered for
|
||||
`output` / `interrupted` / `interrupts` to work.
|
||||
"""
|
||||
super().__init__(mux)
|
||||
self._graph_aiter = graph_aiter
|
||||
self._exhausted = False
|
||||
self._pump_cond = asyncio.Condition()
|
||||
self._pumping = False
|
||||
mux.bind_apump(self._apump_next)
|
||||
|
||||
for projection in projections:
|
||||
mux.wire_channels(projection)
|
||||
async def _apump_next(self) -> bool:
|
||||
"""Drive one pump step, or wait for the active pumper to drive one.
|
||||
|
||||
return GraphRunStream(
|
||||
mux=mux,
|
||||
source=source,
|
||||
transformers=all_transformers,
|
||||
output_mapper=output_mapper,
|
||||
)
|
||||
"Take-a-number" semantics: at most one task at a time calls
|
||||
`graph_aiter.__anext__()` (asyncio iterators can't be advanced
|
||||
concurrently). Other callers wait on a Condition that the
|
||||
active pumper notifies after each step. This lets a "passive"
|
||||
consumer — one whose projection's buffer is being filled by the
|
||||
active pumper's push — wake up as soon as its data lands,
|
||||
instead of queueing on the pump and only observing its data one
|
||||
graph event late.
|
||||
|
||||
`except Exception` is intentional — `CancelledError` and other
|
||||
`BaseException` subclasses propagate, matching asyncio's
|
||||
cancellation contract.
|
||||
|
||||
__all__ = [
|
||||
"AsyncGraphRunStream",
|
||||
"AsyncSubgraphRunStream",
|
||||
"GraphRunStream",
|
||||
"create_async_graph_run_stream",
|
||||
"create_graph_run_stream",
|
||||
]
|
||||
Returns:
|
||||
True if a pump step completed (by this task or another),
|
||||
False if the graph is exhausted.
|
||||
"""
|
||||
async with self._pump_cond:
|
||||
if self._exhausted:
|
||||
return False
|
||||
if self._pumping:
|
||||
# Another task is pumping; wait for its progress signal.
|
||||
await self._pump_cond.wait()
|
||||
return not self._exhausted
|
||||
self._pumping = True
|
||||
|
||||
try:
|
||||
try:
|
||||
part = await self._graph_aiter.__anext__()
|
||||
except StopAsyncIteration:
|
||||
self._exhausted = True
|
||||
await self._mux.aclose()
|
||||
return False
|
||||
except Exception as e:
|
||||
self._exhausted = True
|
||||
await self._mux.afail(e)
|
||||
return False
|
||||
await self._mux.apush(convert_to_protocol_event(part))
|
||||
return True
|
||||
finally:
|
||||
async with self._pump_cond:
|
||||
self._pumping = False
|
||||
self._pump_cond.notify_all()
|
||||
|
||||
async def abort(self) -> None:
|
||||
"""Stop the run early.
|
||||
|
||||
Marks the stream exhausted, wakes any pump-waiters, and closes
|
||||
the mux. Any `apush` blocked on backpressure wakes and returns
|
||||
without appending. Idempotent.
|
||||
"""
|
||||
async with self._pump_cond:
|
||||
if self._exhausted:
|
||||
return
|
||||
self._exhausted = True
|
||||
self._pump_cond.notify_all()
|
||||
try:
|
||||
await self._mux.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> AsyncGraphRunStream:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
await self.abort()
|
||||
|
||||
async def output(self) -> dict[str, Any] | None:
|
||||
"""Drive the run to completion and return the final state.
|
||||
|
||||
Methods (not properties) on the async lane so `run.output`
|
||||
without `await` raises at type-check time instead of silently
|
||||
yielding a coroutine object.
|
||||
|
||||
Example:
|
||||
```python
|
||||
output = await run.output()
|
||||
```
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._values_transformer.error) is not None:
|
||||
raise err
|
||||
return self._values_transformer._latest
|
||||
|
||||
async def interrupted(self) -> bool:
|
||||
"""Drive the run to completion and return whether it was interrupted.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._values_transformer.error) is not None:
|
||||
raise err
|
||||
return self._values_transformer._interrupted
|
||||
|
||||
async def interrupts(self) -> list[Any]:
|
||||
"""Drive the run to completion and return interrupt payloads.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._values_transformer.error) is not None:
|
||||
raise err
|
||||
return self._values_transformer._interrupts
|
||||
|
||||
@@ -1,49 +1,115 @@
|
||||
"""StreamChannel — typed push-based channel for StreamTransformer projections.
|
||||
|
||||
A ``StreamChannel`` wraps a list 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``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Generic, TypeVar
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from typing import 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.
|
||||
"""A named projection channel with optional protocol auto-forwarding.
|
||||
|
||||
Transformer authors create a ``StreamChannel`` in ``init()`` and
|
||||
call ``push()`` inside ``process()`` to emit domain objects. The
|
||||
mux auto-wires pushes to protocol events.
|
||||
Wraps an event log and declares a protocol channel name. When the
|
||||
StreamMux detects a StreamChannel in a transformer's `init()`
|
||||
return value, it automatically wires every `push()` to inject a
|
||||
`ProtocolEvent` into the main event stream using the channel's
|
||||
name as the method.
|
||||
|
||||
Auto-forwarded events bypass the transformer pipeline — other
|
||||
transformers' `process()` / `aprocess()` methods do not see
|
||||
`custom:<name>` events produced by a channel push. This prevents a
|
||||
transformer that pushes to its own channel during `process()` from
|
||||
re-triggering itself, but it also means filter- or tap-style
|
||||
transformers cannot observe channel output from peer transformers.
|
||||
Consumers that need that should iterate the main event stream.
|
||||
|
||||
In-process consumers iterate the channel directly (`for item in ch`
|
||||
or `async for item in ch`). Remote SDK clients subscribe via
|
||||
`session.subscribe("custom:<channelName>")`.
|
||||
|
||||
Like EventLog, a StreamChannel starts unbound. The mux calls
|
||||
`_bind(is_async)` during registration so the correct iteration
|
||||
protocol is available by the time user code sees it.
|
||||
|
||||
Lifecycle (`_close` / `_fail`) is managed by the mux — transformers
|
||||
using only StreamChannels don't need `finalize` or `fail` hooks.
|
||||
"""
|
||||
|
||||
__slots__ = ("channel_name", "_items", "_on_push")
|
||||
def __init__(
|
||||
self, name: str, *, maxlen: int | None = None, retain: bool = False
|
||||
) -> None:
|
||||
"""Initialize the channel with an empty inner log.
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.channel_name = name
|
||||
self._items: list[T] = []
|
||||
self._on_push: Callable[[Any], None] | None = None
|
||||
Args:
|
||||
name: The protocol channel name used for auto-forwarded
|
||||
events (`custom:<name>` on the wire).
|
||||
maxlen: Optional retention cap on the inner EventLog. See
|
||||
`EventLog.__init__` for semantics.
|
||||
retain: If True, the inner log retains pushes before any
|
||||
consumer subscribes — needed for channels whose
|
||||
consumer iterates after events have already flowed
|
||||
(e.g. `lifecycle` inspected after draining `values`).
|
||||
"""
|
||||
self.name = name
|
||||
self._log: EventLog[T] = EventLog(maxlen=maxlen, retain=retain)
|
||||
self._wire_fn: Callable[[T], None] | None = None
|
||||
|
||||
def _bind(self, *, is_async: bool) -> None:
|
||||
"""Bind the underlying event log to sync or async mode.
|
||||
|
||||
Args:
|
||||
is_async: True for async iteration, False for sync.
|
||||
"""
|
||||
self._log._bind(is_async=is_async)
|
||||
|
||||
def push(self, item: T) -> None:
|
||||
"""Push an item to the channel."""
|
||||
self._items.append(item)
|
||||
if self._on_push is not None:
|
||||
self._on_push(item)
|
||||
"""Append an item to the log and auto-forward if wired.
|
||||
|
||||
def _wire(self, fn: Callable[[Any], None]) -> None:
|
||||
"""Wire a callback invoked on every ``push()``. Called by the mux."""
|
||||
self._on_push = fn
|
||||
Args:
|
||||
item: The item to push.
|
||||
"""
|
||||
self._log.push(item)
|
||||
if self._wire_fn is not None:
|
||||
self._wire_fn(item)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mux lifecycle hooks (not called by transformers directly)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_stream_channel(value: object) -> bool:
|
||||
"""Check if *value* is a :class:`StreamChannel` instance."""
|
||||
return isinstance(value, StreamChannel)
|
||||
def _wire(self, fn: Callable[[T], None]) -> None:
|
||||
"""Install the auto-forward callback (called by StreamMux)."""
|
||||
self._wire_fn = fn
|
||||
|
||||
def _close(self) -> None:
|
||||
"""Close the underlying log (called by StreamMux on run end)."""
|
||||
self._log.close()
|
||||
|
||||
__all__ = ["StreamChannel", "is_stream_channel"]
|
||||
def _fail(self, err: BaseException) -> None:
|
||||
"""Fail the underlying log (called by StreamMux on run error)."""
|
||||
self._log.fail(err)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Iteration — delegates to the inner event log (multi-cursor)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
return iter(self._log)
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[T]:
|
||||
return self._log.__aiter__()
|
||||
|
||||
def tee(self, n: int = 2) -> tuple[Iterator[T], ...]:
|
||||
"""Fan out the channel into `n` independent sync iterators.
|
||||
|
||||
Delegates to the underlying EventLog's `tee()`.
|
||||
"""
|
||||
return self._log.tee(n)
|
||||
|
||||
def atee(self, n: int = 2) -> tuple[AsyncIterator[T], ...]:
|
||||
"""Fan out the channel into `n` independent async iterators.
|
||||
|
||||
Delegates to the underlying EventLog's `atee()`.
|
||||
"""
|
||||
return self._log.atee(n)
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,172 +1,585 @@
|
||||
"""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
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from langchain_core.language_models._compat_bridge import message_to_events
|
||||
from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage
|
||||
from langchain_protocol.protocol import CheckpointRef, MessagesData
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.stream._event_log import EventLog
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.chat_model_stream import ChatModelStream
|
||||
from langgraph.stream.run_stream import BaseRunStream
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
# Type alias for the stream class constructor signature
|
||||
_StreamCls = type[ChatModelStream]
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langgraph.stream._mux import StreamMux
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SubgraphStatus = Literal["started", "completed", "failed", "interrupted"]
|
||||
_TERMINAL_STATUSES: frozenset[SubgraphStatus] = frozenset(
|
||||
{"completed", "failed", "interrupted"}
|
||||
)
|
||||
|
||||
|
||||
def _is_new_direct_child(
|
||||
ns: tuple[str, ...],
|
||||
scope: tuple[str, ...],
|
||||
seen: set[tuple[str, ...]] | dict[tuple[str, ...], Any],
|
||||
) -> bool:
|
||||
"""Return True iff `ns` is a direct child of `scope` not yet seen.
|
||||
|
||||
Shared by `SubgraphTransformer` (in-process handle discovery) and
|
||||
`LifecycleTransformer` (wire event emission) so the two can't
|
||||
disagree on what counts as a new subgraph.
|
||||
"""
|
||||
return len(ns) == len(scope) + 1 and ns[:-1] == scope and ns not in seen
|
||||
|
||||
|
||||
def _parse_ns_segment(segment: str) -> tuple[str, str | None]:
|
||||
"""Split `node_name:task_id` into (node_name, task_id).
|
||||
|
||||
Task ids are present when Pregel spawned the subgraph as a task;
|
||||
absent on synthesized namespaces (tests, hand-crafted events).
|
||||
"""
|
||||
node_name, sep, task_id = segment.partition(":")
|
||||
if not sep:
|
||||
return segment, None
|
||||
return node_name, task_id or None
|
||||
|
||||
|
||||
class ValuesTransformer(StreamTransformer):
|
||||
"""Extracts ``values`` events and populates a values log.
|
||||
"""Capture values events as a drainable stream of state snapshots.
|
||||
|
||||
Maintains the latest state per namespace and provides a separate
|
||||
log that :class:`AsyncGraphRunStream` / :class:`GraphRunStream` uses for ``.values``
|
||||
iteration.
|
||||
Keeps `_latest` / `_interrupted` / `_interrupts` as scalar state
|
||||
regardless of whether the log has a subscriber — so `run.output()`
|
||||
and `run.interrupted` work without forcing the caller to iterate
|
||||
`run.values`. Log pushes are silent no-ops when unsubscribed.
|
||||
|
||||
Native transformer — projection keys are exposed as direct
|
||||
attributes on the run stream (e.g. `run.values`).
|
||||
|
||||
`scope` (inherited from `StreamTransformer`) is the namespace the
|
||||
transformer captures values for. `()` matches the root graph;
|
||||
subgraph mini-muxes pass their subgraph's namespace, so each
|
||||
instance sees only its own level.
|
||||
"""
|
||||
|
||||
name = "values"
|
||||
_native = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._values_log: list[dict[str, Any]] = []
|
||||
self._latest: dict[str, Any] = {}
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: EventLog[dict[str, Any]] = EventLog()
|
||||
self._latest: dict[str, Any] | None = None
|
||||
self._interrupted = False
|
||||
self._interrupts: list[Any] = []
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"values": self._log}
|
||||
|
||||
@property
|
||||
def value(self) -> list[dict[str, Any]]:
|
||||
return self._values_log
|
||||
def error(self) -> BaseException | None:
|
||||
"""The error that ended the run, or `None` if it succeeded.
|
||||
|
||||
@property
|
||||
def values_log(self) -> list[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
|
||||
Set by the mux when it auto-fails the projection log.
|
||||
"""
|
||||
return self._log._error
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
# Namespace filtering is handled by the mux via `scope_exact`.
|
||||
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})
|
||||
params = event["params"]
|
||||
self._latest = params["data"]
|
||||
interrupts = params.get("interrupts", ())
|
||||
if interrupts:
|
||||
self._interrupted = True
|
||||
self._interrupts.extend(interrupts)
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MessagesTransformer(StreamTransformer):
|
||||
"""Groups ``messages`` events into :class:`ChatModelStream` instances.
|
||||
"""Capture messages events as ChatModelStream objects.
|
||||
|
||||
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.
|
||||
The messages projection yields one `ChatModelStream` (or
|
||||
`AsyncChatModelStream`) per LLM call. Consumers iterate
|
||||
`run.messages` to get stream handles, then use each handle's typed
|
||||
projections (`.text`, `.reasoning`, `.tool_calls`, `.usage`,
|
||||
`.output`) for per-message content.
|
||||
|
||||
Two input shapes are handled (via `params["data"] = (payload,
|
||||
metadata)` from `StreamMessagesHandler`):
|
||||
|
||||
1. Protocol event (dict with `"event"` key) — emitted by
|
||||
`stream_v2()` / `astream_v2()` via the `on_stream_event`
|
||||
callback. Routed to an existing `ChatModelStream` by
|
||||
`metadata["run_id"]`. A `message-start` event creates a new
|
||||
stream; `message-finish` closes it.
|
||||
2. Whole `AIMessage` — emitted from `on_chain_end` when a node
|
||||
returns a finalized message. Replayed as a synthetic protocol
|
||||
event lifecycle via `message_to_events`, then the
|
||||
already-complete stream is pushed to the log.
|
||||
|
||||
V1 `AIMessageChunk` tuples (from `on_llm_new_token`) are not
|
||||
streamed into this projection: chat models that want to populate
|
||||
`run.messages` with content-block streaming must use
|
||||
`stream_v2()` / `astream_v2()`. Models called via the legacy
|
||||
`stream()` method still surface their final `AIMessage` via
|
||||
`on_chain_end` when a node returns it as state.
|
||||
|
||||
`scope` (inherited from `StreamTransformer`) is the namespace the
|
||||
transformer captures messages for. `()` matches the root graph;
|
||||
subgraph mini-muxes pass their subgraph's namespace, so each
|
||||
instance sees only its own level.
|
||||
|
||||
Native transformer — the `messages` projection is exposed as a
|
||||
direct attribute on the run stream.
|
||||
"""
|
||||
|
||||
name = "messages"
|
||||
_native = True
|
||||
|
||||
def __init__(
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: EventLog[ChatModelStream] = EventLog()
|
||||
# Correlate protocol events back to a ChatModelStream by run_id
|
||||
# (attached to the event's metadata by StreamMessagesHandler).
|
||||
self._by_run: dict[str, ChatModelStream] = {}
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"messages": self._log}
|
||||
|
||||
def _bind_pump(self, fn: Callable[[], bool]) -> None:
|
||||
"""Wire the sync pull callback. Called by GraphRunStream._wire_request_more."""
|
||||
self._pump_fn = fn
|
||||
|
||||
def _bind_apump(self, fn: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Wire the async pull callback.
|
||||
|
||||
Called by `AsyncGraphRunStream._wire_arequest_more` so each
|
||||
`AsyncChatModelStream` this transformer creates can drive the
|
||||
shared graph pump from its projection cursors.
|
||||
"""
|
||||
self._apump_fn = fn
|
||||
|
||||
def _make_stream(
|
||||
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
|
||||
namespace: list[str],
|
||||
node: str | None,
|
||||
message_id: str | None,
|
||||
) -> ChatModelStream:
|
||||
"""Create a ChatModelStream (sync) or AsyncChatModelStream (async).
|
||||
|
||||
# Message log for .messages iteration
|
||||
self._messages_log: list[ChatModelStream] = []
|
||||
|
||||
# Current active stream per namespace key
|
||||
self._active: dict[str, ChatModelStream] = {}
|
||||
|
||||
@property
|
||||
def value(self) -> list[ChatModelStream]:
|
||||
return self._messages_log
|
||||
|
||||
@property
|
||||
def messages_log(self) -> list[ChatModelStream]:
|
||||
return self._messages_log
|
||||
|
||||
def init(self) -> Any:
|
||||
return None
|
||||
Wires whichever pump is bound. Prefers the async pump so nested
|
||||
iteration under `AsyncGraphRunStream` drives the graph forward
|
||||
without a background task. The unwired fallback (no pump bound)
|
||||
is used by unit tests that dispatch events manually.
|
||||
"""
|
||||
if self._apump_fn is not None:
|
||||
astream = AsyncChatModelStream(
|
||||
namespace=namespace,
|
||||
node=node,
|
||||
message_id=message_id,
|
||||
)
|
||||
astream.set_arequest_more(self._apump_fn)
|
||||
return astream
|
||||
if self._pump_fn is not None:
|
||||
stream: ChatModelStream = ChatModelStream(
|
||||
namespace=namespace,
|
||||
node=node,
|
||||
message_id=message_id,
|
||||
)
|
||||
stream.set_request_more(self._pump_fn)
|
||||
return stream
|
||||
return AsyncChatModelStream(
|
||||
namespace=namespace,
|
||||
node=node,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
# Namespace filtering is handled by the mux via `scope_exact`.
|
||||
if event["method"] != "messages":
|
||||
return True
|
||||
params = event["params"]
|
||||
|
||||
ns = event["params"].get("namespace", [])
|
||||
node = event["params"].get("node")
|
||||
data = event["params"]["data"]
|
||||
payload, metadata = params["data"]
|
||||
node: str | None = metadata.get("langgraph_node")
|
||||
run_id = str(metadata.get("run_id", "")) if metadata else ""
|
||||
|
||||
# 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"),
|
||||
if isinstance(payload, dict) and "event" in payload:
|
||||
self._route_protocol_event(
|
||||
cast("MessagesData", payload), run_id=run_id, node=node
|
||||
)
|
||||
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))
|
||||
elif isinstance(payload, BaseMessage) and not isinstance(
|
||||
payload, AIMessageChunk
|
||||
):
|
||||
self._route_whole_message(payload, node=node)
|
||||
# Legacy AIMessageChunk tuples (from on_llm_new_token) are ignored;
|
||||
# v1 streaming callers must switch to stream_v2() to populate this
|
||||
# projection.
|
||||
|
||||
return True
|
||||
|
||||
def _route_protocol_event(
|
||||
self,
|
||||
event: MessagesData,
|
||||
*,
|
||||
run_id: str,
|
||||
node: str | None,
|
||||
) -> None:
|
||||
event_type = event.get("event")
|
||||
if event_type == "message-start":
|
||||
message_id = event.get("message_id")
|
||||
stream = self._make_stream(
|
||||
namespace=list(self.scope),
|
||||
node=node,
|
||||
message_id=str(message_id) if message_id is not None else None,
|
||||
)
|
||||
self._by_run[run_id] = stream
|
||||
self._log.push(stream)
|
||||
stream.dispatch(event)
|
||||
elif run_id in self._by_run:
|
||||
stream = self._by_run[run_id]
|
||||
stream.dispatch(event)
|
||||
if event_type == "message-finish":
|
||||
del self._by_run[run_id]
|
||||
|
||||
def _route_whole_message(self, message: BaseMessage, *, node: str | None) -> None:
|
||||
stream = self._make_stream(
|
||||
namespace=list(self.scope),
|
||||
node=node,
|
||||
message_id=message.id,
|
||||
)
|
||||
for evt in message_to_events(message, message_id=message.id):
|
||||
stream.dispatch(evt)
|
||||
self._log.push(stream)
|
||||
|
||||
def finalize(self) -> None:
|
||||
# Finish any remaining active streams
|
||||
for stream in self._active.values():
|
||||
stream._finish({"reason": "stop"})
|
||||
self._active.clear()
|
||||
"""Clear any routing state — streams close themselves via `message-finish`."""
|
||||
self._by_run.clear()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
for stream in self._active.values():
|
||||
stream._fail(err)
|
||||
self._active.clear()
|
||||
"""Propagate run error to any streams still open when the graph fails."""
|
||||
for stream in list(self._by_run.values()):
|
||||
stream.fail(err)
|
||||
self._by_run.clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MessagesTransformer",
|
||||
"ValuesTransformer",
|
||||
]
|
||||
class SubgraphRunStream(BaseRunStream):
|
||||
"""Scoped view of a single nested subgraph execution.
|
||||
|
||||
Yielded on `run.subgraphs` (or `parent.subgraphs` for grandchildren)
|
||||
when a nested `Pregel` spawns. Wraps a mini-`StreamMux` built with
|
||||
the same transformer factories as the root mux, so `.values`,
|
||||
`.messages`, `.subgraphs` are populated by the standard
|
||||
transformers scoped to this handle's namespace — no duplicated
|
||||
routing logic. The mini-mux borrows the root's pump via
|
||||
`make_child`'s pump inheritance, so any cursor on a subagent
|
||||
projection drives the whole run forward.
|
||||
|
||||
Handle fields:
|
||||
|
||||
- `path`: the namespace tuple — stable for the life of the handle.
|
||||
- `graph_name` / `trigger_call_id`: parsed from the namespace
|
||||
segment at discovery (`node_name:task_id`).
|
||||
- `status`: `started` on discovery; advances to `completed` when
|
||||
the parent mux closes, or `failed` / `interrupted` when it
|
||||
errors.
|
||||
- `error`: set on terminal error.
|
||||
- `checkpoint`: unused by the current discovery path — kept for
|
||||
compatibility with consumers that inspect it.
|
||||
|
||||
`.output` is a snapshot of the latest values seen at this
|
||||
namespace — it doesn't drive the pump (unlike root's
|
||||
`GraphRunStream.output`), because advancing a subgraph to
|
||||
completion is only meaningful as part of advancing the whole run.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: tuple[str, ...],
|
||||
mux: StreamMux,
|
||||
*,
|
||||
graph_name: str | None = None,
|
||||
trigger_call_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(mux)
|
||||
self.path: tuple[str, ...] = path
|
||||
self.graph_name: str | None = graph_name
|
||||
self.trigger_call_id: str | None = trigger_call_id
|
||||
self.status: SubgraphStatus = "started"
|
||||
self.error: str | None = None
|
||||
self.checkpoint: CheckpointRef | None = None
|
||||
|
||||
@property
|
||||
def output(self) -> dict[str, Any] | None:
|
||||
"""Latest values snapshot at this namespace, or `None`.
|
||||
|
||||
Snapshot-only — iterating other projections or the root's
|
||||
`.output` is what drives the pump.
|
||||
"""
|
||||
values_t = self._mux.transformer_by_key("values")
|
||||
if isinstance(values_t, ValuesTransformer):
|
||||
return values_t._latest
|
||||
return None
|
||||
|
||||
|
||||
class SubgraphTransformer(StreamTransformer):
|
||||
"""Discover subgraphs and route events into per-subgraph mini-muxes.
|
||||
|
||||
Thin dispatcher. At its own `scope` (inherited from
|
||||
`StreamTransformer`, determined by the enclosing mux), it watches
|
||||
for the first event at exactly one namespace level deeper to
|
||||
discover a direct child. Each discovered child gets its own
|
||||
`SubgraphRunStream` backed by a mini-`StreamMux` — built via
|
||||
`parent_mux.make_child(path)`, so the same factory list produces
|
||||
fresh transformer instances at the child's scope.
|
||||
|
||||
Every incoming event that falls under one of the direct children
|
||||
(ns starts with a child's `path`) is forwarded into that child's
|
||||
mini-mux via `push`. The standard transformers in that mini-mux
|
||||
(`ValuesTransformer`, `MessagesTransformer`, and another
|
||||
`SubgraphTransformer` for grandchildren) handle the rest. No
|
||||
duplicated routing or assembly logic.
|
||||
|
||||
Discovery is method-agnostic: the first event of any mode whose
|
||||
namespace places it directly below `scope` spawns the handle.
|
||||
`graph_name` and `trigger_call_id` are parsed from the namespace
|
||||
segment, which encodes `node_name:task_id`.
|
||||
|
||||
Terminal status for each handle is set by the parent mux's
|
||||
`close` / `fail` path. `finalize` transitions still-open handles
|
||||
to `completed`; `fail` transitions them to `failed` or
|
||||
`interrupted` depending on the error.
|
||||
|
||||
Native transformer — `subgraphs` exposes the direct-children log.
|
||||
|
||||
`scope_exact = False`: this transformer sees events at any
|
||||
namespace, because it forwards out-of-scope events to the matching
|
||||
direct-child mini-mux.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
scope_exact = False
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._root_log: EventLog[SubgraphRunStream] = EventLog()
|
||||
# Direct children only (namespace = scope + one segment).
|
||||
self._by_ns: dict[tuple[str, ...], SubgraphRunStream] = {}
|
||||
self._mux: StreamMux | None = None
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"subgraphs": self._root_log}
|
||||
|
||||
def _on_register(self, mux: StreamMux) -> None:
|
||||
"""Capture the enclosing mux so we can build child mini-muxes."""
|
||||
self._mux = mux
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
ns = tuple(event["params"]["namespace"])
|
||||
depth = len(self.scope)
|
||||
|
||||
# 1. Discover: first-seen direct-child namespace registers a
|
||||
# handle. Any event method triggers discovery — no dedicated
|
||||
# channel.
|
||||
if _is_new_direct_child(ns, self.scope, self._by_ns):
|
||||
self._on_started(ns)
|
||||
|
||||
# 2. Forward the event to the matching direct-child mini-mux.
|
||||
# Prefix-match: ns must start with some child's path.
|
||||
direct_child_ns = ns[: depth + 1] if len(ns) > depth else None
|
||||
if direct_child_ns is not None and direct_child_ns in self._by_ns:
|
||||
self._by_ns[direct_child_ns]._mux.push(event)
|
||||
|
||||
return True
|
||||
|
||||
def _on_started(self, ns: tuple[str, ...]) -> None:
|
||||
# `_on_register` is called by the mux during registration, which
|
||||
# happens before any event can be dispatched — so this should
|
||||
# always be set by the time we process an event.
|
||||
assert self._mux is not None, (
|
||||
"SubgraphTransformer processed an event before _on_register; "
|
||||
"transformer registration ordering is broken."
|
||||
)
|
||||
graph_name, trigger_call_id = _parse_ns_segment(ns[-1])
|
||||
child_mux = self._mux.make_child(ns)
|
||||
handle = SubgraphRunStream(
|
||||
path=ns,
|
||||
mux=child_mux,
|
||||
graph_name=graph_name,
|
||||
trigger_call_id=trigger_call_id,
|
||||
)
|
||||
self._by_ns[ns] = handle
|
||||
self._root_log.push(handle)
|
||||
|
||||
@staticmethod
|
||||
def _close_handle_mux(handle: SubgraphRunStream) -> None:
|
||||
# Idempotent close — mux.close() runs finalize on its transformers
|
||||
# (which cascades through grandchildren) and closes projection logs.
|
||||
if not handle._mux._events._closed:
|
||||
try:
|
||||
handle._mux.close()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Error closing subgraph mini-mux at %s; subscribers "
|
||||
"may not see a clean close.",
|
||||
handle.path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Transition any still-open direct children to `completed`.
|
||||
|
||||
Subgraph interrupts surface as a values event with a populated
|
||||
`interrupts` field rather than as an exception at the graph
|
||||
boundary — the parent pump exhausts normally and `finalize`
|
||||
runs the close path. Inspect each child's `ValuesTransformer`
|
||||
to distinguish "completed cleanly" from "interrupted".
|
||||
"""
|
||||
for handle in self._by_ns.values():
|
||||
if handle.status not in _TERMINAL_STATUSES:
|
||||
values_t = handle._mux.transformer_by_key("values")
|
||||
if isinstance(values_t, ValuesTransformer) and values_t._interrupted:
|
||||
handle.status = "interrupted"
|
||||
else:
|
||||
handle.status = "completed"
|
||||
self._close_handle_mux(handle)
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Transition any still-open direct children to `failed` / `interrupted`."""
|
||||
is_interrupt = isinstance(err, GraphInterrupt)
|
||||
terminal: SubgraphStatus = "interrupted" if is_interrupt else "failed"
|
||||
error_str = None if is_interrupt else str(err)
|
||||
for handle in self._by_ns.values():
|
||||
if handle.status not in _TERMINAL_STATUSES:
|
||||
handle.status = terminal
|
||||
if error_str is not None and handle.error is None:
|
||||
handle.error = error_str
|
||||
if not handle._mux._events._closed:
|
||||
try:
|
||||
handle._mux.fail(err)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Error failing subgraph mini-mux at %s; subscribers "
|
||||
"may not see the terminal error.",
|
||||
handle.path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class LifecyclePayload(TypedDict, total=False):
|
||||
"""Payload of a lifecycle event emitted by `LifecycleTransformer`."""
|
||||
|
||||
event: SubgraphStatus
|
||||
namespace: list[str]
|
||||
graph_name: str | None
|
||||
trigger_call_id: str | None
|
||||
error: str | None
|
||||
|
||||
|
||||
class LifecycleTransformer(StreamTransformer):
|
||||
"""Synthesize subgraph lifecycle events from observed namespaces.
|
||||
|
||||
Observes the same namespace signal `SubgraphTransformer` uses for
|
||||
in-process discovery and emits `started` / `completed` / `failed`
|
||||
/ `interrupted` payloads onto its `lifecycle` channel. Consumers
|
||||
subscribed to that channel see the events in-process; wire
|
||||
consumers receive them as protocol events with `method:
|
||||
"lifecycle"` (unprefixed because this transformer is `_native`).
|
||||
|
||||
No `running` event: the ns-discovery signal only fires once a
|
||||
subgraph has emitted output, so `started` already implies
|
||||
execution. Consumers needing finer-grained task-start visibility
|
||||
should read the `tasks` stream mode alongside.
|
||||
|
||||
No root `started`: the run object itself signals run start.
|
||||
|
||||
Terminal events are synthesized — `finalize` emits `completed` for
|
||||
still-open handles; `fail` emits `failed` or `interrupted`
|
||||
depending on whether the error is a `GraphInterrupt`.
|
||||
|
||||
`scope_exact = False` so the transformer sees events at any
|
||||
namespace (needed for discovery of direct children).
|
||||
"""
|
||||
|
||||
_native = True
|
||||
scope_exact = False
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
# retain=True: lifecycle events are low-volume and consumers
|
||||
# commonly inspect them after draining `values`; without
|
||||
# retention those pushes would be dropped.
|
||||
self._channel: StreamChannel[LifecyclePayload] = StreamChannel(
|
||||
"lifecycle", retain=True
|
||||
)
|
||||
self._seen: set[tuple[str, ...]] = set()
|
||||
self._open: set[tuple[str, ...]] = set()
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"lifecycle": self._channel}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
ns = tuple(event["params"]["namespace"])
|
||||
if _is_new_direct_child(ns, self.scope, self._seen):
|
||||
self._emit_started(ns)
|
||||
return True
|
||||
|
||||
def _emit_started(self, ns: tuple[str, ...]) -> None:
|
||||
graph_name, trigger_call_id = _parse_ns_segment(ns[-1])
|
||||
self._seen.add(ns)
|
||||
self._open.add(ns)
|
||||
payload: LifecyclePayload = {
|
||||
"event": "started",
|
||||
"namespace": list(ns),
|
||||
}
|
||||
if graph_name:
|
||||
payload["graph_name"] = graph_name
|
||||
if trigger_call_id is not None:
|
||||
payload["trigger_call_id"] = trigger_call_id
|
||||
self._channel.push(payload)
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Emit `completed` for every still-open direct child."""
|
||||
for ns in list(self._open):
|
||||
self._channel.push({"event": "completed", "namespace": list(ns)})
|
||||
self._open.clear()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Emit `failed` / `interrupted` for every still-open direct child.
|
||||
|
||||
Closes the channel after emitting rather than letting the mux
|
||||
auto-fail it — the "failed" payload is the signal to
|
||||
consumers, so they should be able to iterate it. A failed
|
||||
channel would raise on iteration and hide the events that just
|
||||
got pushed.
|
||||
"""
|
||||
is_interrupt = isinstance(err, GraphInterrupt)
|
||||
event_type: SubgraphStatus = "interrupted" if is_interrupt else "failed"
|
||||
error_str = None if is_interrupt else str(err)
|
||||
for ns in list(self._open):
|
||||
payload: LifecyclePayload = {
|
||||
"event": event_type,
|
||||
"namespace": list(ns),
|
||||
}
|
||||
if error_str is not None:
|
||||
payload["error"] = error_str
|
||||
self._channel.push(payload)
|
||||
self._open.clear()
|
||||
self._channel._close()
|
||||
|
||||
@@ -116,7 +116,13 @@ def ensure_valid_checkpointer(checkpointer: Checkpointer) -> Checkpointer:
|
||||
|
||||
|
||||
StreamMode = Literal[
|
||||
"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"
|
||||
"values",
|
||||
"updates",
|
||||
"checkpoints",
|
||||
"tasks",
|
||||
"debug",
|
||||
"messages",
|
||||
"custom",
|
||||
]
|
||||
"""How the stream method should emit outputs.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.1.6"
|
||||
version = "1.1.7a2"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
'Programming Language :: Python :: 3.13',
|
||||
]
|
||||
dependencies = [
|
||||
"langchain-core>=0.1",
|
||||
"langchain-core==1.3.2",
|
||||
"langgraph-checkpoint>=2.1.0,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-prebuilt>=1.0.9,<1.1.0",
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
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
|
||||
@@ -1,623 +0,0 @@
|
||||
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, StreamTransformer
|
||||
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(StreamTransformer):
|
||||
"""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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Double iteration over .values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_values_double_iteration():
|
||||
"""Iterating over run.values twice should yield the same snapshots both times."""
|
||||
graph = make_simple_graph()
|
||||
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
first = []
|
||||
async for v in run.values:
|
||||
first.append(v)
|
||||
|
||||
second = []
|
||||
async for v in run.values:
|
||||
second.append(v)
|
||||
|
||||
assert len(first) == 3
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_sync_values_double_iteration():
|
||||
"""Iterating over run.values twice should yield the same snapshots both times."""
|
||||
graph = make_simple_graph()
|
||||
run = StreamingHandler(graph).stream({"value": "x", "items": []})
|
||||
|
||||
first = list(run.values)
|
||||
second = list(run.values)
|
||||
|
||||
assert len(first) == 3
|
||||
assert first == second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_raw_events_double_iteration():
|
||||
"""Iterating over the raw event stream twice should yield the same events."""
|
||||
graph = make_simple_graph()
|
||||
run = await StreamingHandler(graph).astream({"value": "x", "items": []})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
first = []
|
||||
async for event in run:
|
||||
first.append(event)
|
||||
|
||||
second = []
|
||||
async for event in run:
|
||||
second.append(event)
|
||||
|
||||
assert len(first) > 0
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_sync_raw_events_double_iteration():
|
||||
"""Iterating over the raw event stream twice should yield the same events."""
|
||||
graph = make_simple_graph()
|
||||
run = StreamingHandler(graph).stream({"value": "x", "items": []})
|
||||
|
||||
first = list(run)
|
||||
second = list(run)
|
||||
|
||||
assert len(first) > 0
|
||||
assert first == second
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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(StreamTransformer):
|
||||
"""Groups tool-started/tool-finished custom events into _ToolExecution objects."""
|
||||
|
||||
name = "tools"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._log: list[_ToolExecution] = []
|
||||
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:
|
||||
pass
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
pass
|
||||
|
||||
|
||||
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}
|
||||
@@ -1396,7 +1396,6 @@ 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",
|
||||
@@ -1459,7 +1458,6 @@ 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",
|
||||
@@ -1512,7 +1510,6 @@ 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",
|
||||
@@ -6884,7 +6881,6 @@ 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",
|
||||
@@ -6912,7 +6908,6 @@ 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",
|
||||
@@ -6949,7 +6944,6 @@ 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,7 +1147,6 @@ 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",
|
||||
@@ -1210,7 +1209,6 @@ 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",
|
||||
@@ -1263,7 +1261,6 @@ 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",
|
||||
@@ -3981,7 +3978,6 @@ 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",
|
||||
@@ -4009,7 +4005,6 @@ 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",
|
||||
@@ -4046,7 +4041,6 @@ 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",
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
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"
|
||||
@@ -6271,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.time()}
|
||||
return {"tic": time.monotonic()}
|
||||
|
||||
@entrypoint()
|
||||
def graph(inputs: dict) -> list:
|
||||
@@ -6284,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.time())
|
||||
arrival_times.append(time.monotonic())
|
||||
|
||||
assert len(arrival_times) == 2
|
||||
delta = arrival_times[1] - arrival_times[0]
|
||||
@@ -6893,7 +6893,6 @@ 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",
|
||||
@@ -6903,6 +6902,60 @@ 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
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -7541,7 +7542,6 @@ 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",
|
||||
@@ -7551,6 +7551,67 @@ 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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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_metadata() -> None:
|
||||
"""server_info is built from assistant_id/graph_id in config metadata."""
|
||||
def test_server_info_from_configurable() -> None:
|
||||
"""server_info is built from assistant_id/graph_id in config configurable."""
|
||||
captured: dict[str, Any] = {}
|
||||
compiled = _make_capture_graph(captured)
|
||||
compiled.invoke(
|
||||
{"message": "hi"},
|
||||
config={"metadata": {"assistant_id": "asst-abc", "graph_id": "my-graph"}},
|
||||
config={"configurable": {"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_metadata() -> None:
|
||||
assert si.user is None
|
||||
|
||||
|
||||
def test_server_info_none_without_metadata() -> None:
|
||||
"""server_info is None when no assistant_id/graph_id in metadata."""
|
||||
def test_server_info_none_without_configurable() -> None:
|
||||
"""server_info is None when no assistant_id/graph_id in configurable."""
|
||||
captured: dict[str, Any] = {}
|
||||
compiled = _make_capture_graph(captured)
|
||||
compiled.invoke({"message": "hi"})
|
||||
@@ -579,8 +579,11 @@ def test_server_info_user_from_auth_user() -> None:
|
||||
compiled.invoke(
|
||||
{"message": "hi"},
|
||||
config={
|
||||
"configurable": {"langgraph_auth_user": proxy},
|
||||
"metadata": {"assistant_id": "asst-proxy", "graph_id": "graph-proxy"},
|
||||
"configurable": {
|
||||
"langgraph_auth_user": proxy,
|
||||
"assistant_id": "asst-proxy",
|
||||
"graph_id": "graph-proxy",
|
||||
},
|
||||
},
|
||||
)
|
||||
si = captured["server_info"]
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
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)
|
||||
@@ -1,79 +0,0 @@
|
||||
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_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,258 @@
|
||||
"""Tests for LifecycleTransformer — derives subgraph lifecycle from ns discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
from langgraph.stream.transformers import (
|
||||
LifecycleTransformer,
|
||||
ValuesTransformer,
|
||||
)
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _event(method: str, data: Any, *, namespace: list[str]) -> ProtocolEvent:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": method,
|
||||
"params": {
|
||||
"namespace": namespace,
|
||||
"timestamp": TS,
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _drain_channel(mux: StreamMux) -> list[dict[str, Any]]:
|
||||
ch = mux.extensions["lifecycle"]
|
||||
return list(ch._log._items) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _drain_main_events(mux: StreamMux) -> list[ProtocolEvent]:
|
||||
return list(mux._events._items) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _subscribe(mux: StreamMux) -> None:
|
||||
ch = mux.extensions["lifecycle"]
|
||||
ch._log._subscribed = True # type: ignore[attr-defined]
|
||||
mux._events._subscribed = True # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestLifecycleTransformerUnit:
|
||||
def _mux(self) -> StreamMux:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, LifecycleTransformer], is_async=False
|
||||
)
|
||||
_subscribe(mux)
|
||||
return mux
|
||||
|
||||
def test_first_event_at_child_ns_emits_started(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("values", {"v": 1}, namespace=["child:task_a"]))
|
||||
|
||||
events = _drain_channel(mux)
|
||||
assert events == [
|
||||
{
|
||||
"event": "started",
|
||||
"namespace": ["child:task_a"],
|
||||
"graph_name": "child",
|
||||
"trigger_call_id": "task_a",
|
||||
}
|
||||
]
|
||||
|
||||
def test_no_started_at_root_ns(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("values", {"v": 1}, namespace=[]))
|
||||
assert _drain_channel(mux) == []
|
||||
|
||||
def test_method_agnostic_discovery(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("messages", "x", namespace=["c:t"]))
|
||||
|
||||
(started,) = _drain_channel(mux)
|
||||
assert started["event"] == "started"
|
||||
assert started["namespace"] == ["c:t"]
|
||||
|
||||
def test_repeated_events_single_started(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("values", {"v": 1}, namespace=["c:t"]))
|
||||
mux.push(_event("values", {"v": 2}, namespace=["c:t"]))
|
||||
mux.push(_event("updates", {"n": "x"}, namespace=["c:t"]))
|
||||
|
||||
events = _drain_channel(mux)
|
||||
assert len(events) == 1
|
||||
assert events[0]["event"] == "started"
|
||||
|
||||
def test_ns_without_task_id(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("values", {"v": 1}, namespace=["child"]))
|
||||
|
||||
(started,) = _drain_channel(mux)
|
||||
assert started["graph_name"] == "child"
|
||||
assert "trigger_call_id" not in started
|
||||
|
||||
def test_finalize_emits_completed(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("values", {"v": 1}, namespace=["c:t"]))
|
||||
mux.close()
|
||||
|
||||
events = _drain_channel(mux)
|
||||
assert events == [
|
||||
{
|
||||
"event": "started",
|
||||
"namespace": ["c:t"],
|
||||
"graph_name": "c",
|
||||
"trigger_call_id": "t",
|
||||
},
|
||||
{"event": "completed", "namespace": ["c:t"]},
|
||||
]
|
||||
|
||||
def test_fail_with_graph_interrupt(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("values", {"v": 1}, namespace=["c:t"]))
|
||||
mux.fail(GraphInterrupt())
|
||||
|
||||
events = _drain_channel(mux)
|
||||
assert events[-1] == {"event": "interrupted", "namespace": ["c:t"]}
|
||||
|
||||
def test_fail_with_generic_error(self) -> None:
|
||||
mux = self._mux()
|
||||
mux.push(_event("values", {"v": 1}, namespace=["c:t"]))
|
||||
mux.fail(RuntimeError("boom"))
|
||||
|
||||
events = _drain_channel(mux)
|
||||
assert events[-1] == {
|
||||
"event": "failed",
|
||||
"namespace": ["c:t"],
|
||||
"error": "boom",
|
||||
}
|
||||
|
||||
|
||||
class TestLifecycleWireFormat:
|
||||
"""Native transformer: method on the wire is `"lifecycle"`, no `custom:` prefix."""
|
||||
|
||||
def test_wire_method_is_lifecycle_unprefixed(self) -> None:
|
||||
mux = StreamMux(factories=[LifecycleTransformer], is_async=False)
|
||||
_subscribe(mux)
|
||||
mux.push(_event("values", {"v": 1}, namespace=["c:t"]))
|
||||
|
||||
# Find the lifecycle event in the main log.
|
||||
lifecycle_events = [
|
||||
ev for ev in _drain_main_events(mux) if ev["method"] == "lifecycle"
|
||||
]
|
||||
assert len(lifecycle_events) == 1
|
||||
assert lifecycle_events[0]["params"]["data"]["event"] == "started"
|
||||
# Also verify no `custom:lifecycle` leaks through.
|
||||
assert not any(
|
||||
ev["method"].startswith("custom:") for ev in _drain_main_events(mux)
|
||||
)
|
||||
|
||||
def test_started_precedes_originating_event_on_wire(self) -> None:
|
||||
"""Seq ordering: synthesized lifecycle event lands before the event that triggered it."""
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, LifecycleTransformer], is_async=False
|
||||
)
|
||||
_subscribe(mux)
|
||||
mux.push(_event("values", {"v": 1}, namespace=["c:t"]))
|
||||
|
||||
wire = _drain_main_events(mux)
|
||||
methods = [ev["method"] for ev in wire]
|
||||
# Lifecycle's synthetic event is forwarded during process() and
|
||||
# gets an earlier seq than the originating values event.
|
||||
assert methods.index("lifecycle") < methods.index("values")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end via stream_v2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SimpleState(TypedDict):
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _build_nested_graph():
|
||||
def inner_node(state: SimpleState) -> dict:
|
||||
return {"value": state["value"] + "X", "items": ["x"]}
|
||||
|
||||
inner_builder = StateGraph(SimpleState)
|
||||
inner_builder.add_node("inner_node", inner_node)
|
||||
inner_builder.add_edge(START, "inner_node")
|
||||
inner_builder.add_edge("inner_node", END)
|
||||
inner = inner_builder.compile()
|
||||
|
||||
outer_builder = StateGraph(SimpleState)
|
||||
outer_builder.add_node("sub", inner)
|
||||
outer_builder.add_edge(START, "sub")
|
||||
outer_builder.add_edge("sub", END)
|
||||
return outer_builder.compile()
|
||||
|
||||
|
||||
class TestLifecycleEndToEnd:
|
||||
def test_real_run_emits_started_and_completed(self) -> None:
|
||||
graph = _build_nested_graph()
|
||||
run = graph.stream_v2(
|
||||
{"value": "", "items": []}, transformers=[LifecycleTransformer]
|
||||
)
|
||||
|
||||
# Drain the run so finalize fires.
|
||||
list(run.values)
|
||||
|
||||
lifecycle = list(run.lifecycle) # type: ignore[attr-defined]
|
||||
events = [e["event"] for e in lifecycle]
|
||||
assert "started" in events
|
||||
assert "completed" in events
|
||||
|
||||
def test_real_run_error_emits_failed(self) -> None:
|
||||
def boom(state: SimpleState) -> dict:
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
inner_builder = StateGraph(SimpleState)
|
||||
inner_builder.add_node("inner", boom)
|
||||
inner_builder.add_edge(START, "inner")
|
||||
inner_builder.add_edge("inner", END)
|
||||
inner = inner_builder.compile()
|
||||
|
||||
outer_builder = StateGraph(SimpleState)
|
||||
outer_builder.add_node("sub", inner)
|
||||
outer_builder.add_edge(START, "sub")
|
||||
outer_builder.add_edge("sub", END)
|
||||
graph = outer_builder.compile()
|
||||
|
||||
run = graph.stream_v2(
|
||||
{"value": "", "items": []}, transformers=[LifecycleTransformer]
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
list(run.values)
|
||||
|
||||
lifecycle = list(run.lifecycle) # type: ignore[attr-defined]
|
||||
events = [e["event"] for e in lifecycle]
|
||||
assert "failed" in events
|
||||
|
||||
def test_lifecycle_and_subgraphs_agree(self) -> None:
|
||||
"""SubgraphTransformer and LifecycleTransformer share the discovery predicate."""
|
||||
graph = _build_nested_graph()
|
||||
run = graph.stream_v2(
|
||||
{"value": "", "items": []}, transformers=[LifecycleTransformer]
|
||||
)
|
||||
|
||||
subs = list(run.subgraphs)
|
||||
lifecycle = list(run.lifecycle) # type: ignore[attr-defined]
|
||||
|
||||
sub_paths = {tuple(s.path) for s in subs}
|
||||
started_paths = {
|
||||
tuple(e["namespace"]) for e in lifecycle if e["event"] == "started"
|
||||
}
|
||||
assert sub_paths == started_paths
|
||||
@@ -0,0 +1,907 @@
|
||||
"""Tests for the MessagesTransformer content-block upgrade (B2).
|
||||
|
||||
Verifies that `MessagesTransformer` routes protocol events (emitted by
|
||||
`stream_v2` via `on_stream_event`) to `ChatModelStream` objects keyed by
|
||||
run_id, and replays whole `AIMessage` payloads via `message_to_events`.
|
||||
Legacy v1 `AIMessageChunk` tuples (from `on_llm_new_token`) are ignored.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import GenericFakeChatModel
|
||||
from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import MessagesState, StateGraph
|
||||
from langgraph.stream._event_log import EventLog
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream.run_stream import GraphRunStream
|
||||
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _proto_event(
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
run_id: str = "run-1",
|
||||
node: str = "llm",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a messages ProtocolEvent carrying a protocol event dict (v2 path)."""
|
||||
metadata: dict[str, Any] = {"langgraph_node": node, "run_id": run_id}
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (event, metadata),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _v1_chunk(
|
||||
text: str,
|
||||
msg_id: str = "msg-1",
|
||||
*,
|
||||
finish: bool = False,
|
||||
node: str = "llm",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a messages ProtocolEvent carrying a v1 AIMessageChunk tuple."""
|
||||
rm: dict[str, Any] = {}
|
||||
if finish:
|
||||
rm["finish_reason"] = "stop"
|
||||
message = AIMessageChunk(content=text, id=msg_id, response_metadata=rm)
|
||||
metadata: dict[str, Any] = {"langgraph_node": node}
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (message, metadata),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _whole_msg(
|
||||
text: str,
|
||||
msg_id: str = "msg-10",
|
||||
*,
|
||||
node: str = "node",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a messages ProtocolEvent carrying a completed AIMessage."""
|
||||
message = AIMessage(content=text, id=msg_id)
|
||||
metadata: dict[str, Any] = {"langgraph_node": node}
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (message, metadata),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_sync_transformer() -> tuple[MessagesTransformer, EventLog[ChatModelStream]]:
|
||||
t = MessagesTransformer()
|
||||
proj = t.init()
|
||||
log: EventLog[ChatModelStream] = proj["messages"]
|
||||
log._bind(is_async=False)
|
||||
# Production subscribes via `iter(log)` from the graph consumer — do that
|
||||
# up front so `push` during `process` isn't a no-op. Tests read buffered
|
||||
# items via `log._items` directly rather than re-iterating.
|
||||
log._subscribed = True
|
||||
t._bind_pump(lambda: False)
|
||||
return t, log
|
||||
|
||||
|
||||
def _make_async_transformer() -> tuple[MessagesTransformer, EventLog[ChatModelStream]]:
|
||||
t = MessagesTransformer()
|
||||
proj = t.init()
|
||||
log: EventLog[ChatModelStream] = proj["messages"]
|
||||
log._bind(is_async=True)
|
||||
log._subscribed = True
|
||||
return t, log
|
||||
|
||||
|
||||
# Standard lifecycle events for one streaming LLM call.
|
||||
def _lifecycle(
|
||||
*,
|
||||
text: str = "hello world",
|
||||
message_id: str = "run-1",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Produce a valid protocol event lifecycle: start, delta, finish, end."""
|
||||
# Split text into two deltas to exercise delta accumulation.
|
||||
half = len(text) // 2
|
||||
first, second = text[:half], text[half:]
|
||||
return [
|
||||
{"event": "message-start", "role": "ai", "message_id": message_id},
|
||||
{
|
||||
"event": "content-block-start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": first},
|
||||
},
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": second},
|
||||
},
|
||||
{
|
||||
"event": "content-block-finish",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": text},
|
||||
},
|
||||
{"event": "message-finish", "reason": "stop"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primary path: protocol event routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtocolEventRouting:
|
||||
def test_message_start_creates_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "role": "ai", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
)
|
||||
)
|
||||
# Stream is in the log immediately.
|
||||
log.close()
|
||||
streams = list(log._items)
|
||||
assert len(streams) == 1
|
||||
assert isinstance(streams[0], ChatModelStream)
|
||||
assert streams[0].message_id == "run-1"
|
||||
|
||||
def test_full_lifecycle_yields_done_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in _lifecycle(text="hello world"):
|
||||
t.process(_proto_event(evt, run_id="run-1"))
|
||||
log.close()
|
||||
(stream,) = list(log._items)
|
||||
assert stream.done
|
||||
assert stream.output.text == "hello world"
|
||||
|
||||
def test_message_finish_cleans_up_routing(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in _lifecycle():
|
||||
t.process(_proto_event(evt, run_id="run-1"))
|
||||
assert t._by_run == {}
|
||||
|
||||
def test_events_without_prior_start_are_ignored(self) -> None:
|
||||
"""Orphan delta events (no preceding message-start) are dropped silently."""
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": "orphan"},
|
||||
},
|
||||
run_id="unknown",
|
||||
)
|
||||
)
|
||||
log.close()
|
||||
assert list(log._items) == []
|
||||
|
||||
def test_concurrent_streams_routed_by_run_id(self) -> None:
|
||||
"""Two interleaved LLM calls each produce their own stream."""
|
||||
t, log = _make_sync_transformer()
|
||||
# Interleave events from two different run_ids.
|
||||
life_a = _lifecycle(text="aaaa", message_id="run-a")
|
||||
life_b = _lifecycle(text="bbbb", message_id="run-b")
|
||||
for a, b in zip(life_a, life_b):
|
||||
t.process(_proto_event(a, run_id="run-a"))
|
||||
t.process(_proto_event(b, run_id="run-b"))
|
||||
log.close()
|
||||
streams = list(log._items)
|
||||
assert len(streams) == 2
|
||||
by_id = {s.message_id: s for s in streams}
|
||||
assert by_id["run-a"].output.text == "aaaa"
|
||||
assert by_id["run-b"].output.text == "bbbb"
|
||||
|
||||
def test_text_deltas_accumulated_on_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in _lifecycle(text="abcdef"):
|
||||
t.process(_proto_event(evt))
|
||||
log.close()
|
||||
(stream,) = list(log._items)
|
||||
deltas = list(stream._text_proj._deltas)
|
||||
assert "".join(deltas) == "abcdef"
|
||||
|
||||
def test_stream_pushed_on_message_start_not_finish(self) -> None:
|
||||
"""Consumer can see the stream before it finishes."""
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "role": "ai", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
)
|
||||
)
|
||||
# The log has the stream immediately — even though message-finish
|
||||
# hasn't arrived yet.
|
||||
assert len(log._items) == 1
|
||||
|
||||
def test_node_metadata_set_on_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "role": "ai", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
node="my_llm",
|
||||
)
|
||||
)
|
||||
(stream,) = [*log._items]
|
||||
assert stream.node == "my_llm"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-streaming (whole AIMessage) fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWholeMessageFallback:
|
||||
def test_whole_ai_message_produces_complete_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_whole_msg("the full answer"))
|
||||
log.close()
|
||||
(stream,) = list(log._items)
|
||||
assert stream.done
|
||||
assert stream.output.text == "the full answer"
|
||||
|
||||
def test_whole_message_has_full_lifecycle(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_whole_msg("full"))
|
||||
log.close()
|
||||
(stream,) = list(log._items)
|
||||
event_types = [e["event"] for e in stream._events]
|
||||
assert event_types == [
|
||||
"message-start",
|
||||
"content-block-start",
|
||||
"content-block-delta",
|
||||
"content-block-finish",
|
||||
"message-finish",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy v1 chunks are ignored (users must migrate to stream_v2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLegacyChunksIgnored:
|
||||
def test_aimessage_chunk_tuple_is_dropped(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_v1_chunk("hello"))
|
||||
t.process(_v1_chunk(" world", finish=True))
|
||||
log.close()
|
||||
assert list(log._items) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filtering behaviors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFiltering:
|
||||
def test_non_messages_events_pass_through(self) -> None:
|
||||
t, _ = _make_sync_transformer()
|
||||
values_event = {
|
||||
"type": "event",
|
||||
"method": "values",
|
||||
"params": {"namespace": [], "timestamp": TS, "data": {"x": 1}},
|
||||
}
|
||||
assert t.process(values_event) is True
|
||||
|
||||
def test_subgraph_namespace_dropped(self) -> None:
|
||||
"""Root MessagesTransformer (via the mux) ignores non-root events."""
|
||||
from langgraph.stream._mux import StreamMux
|
||||
|
||||
mux = StreamMux([MessagesTransformer()], is_async=False)
|
||||
t = mux.transformer_by_key("messages")
|
||||
assert isinstance(t, MessagesTransformer)
|
||||
t._log._subscribed = True
|
||||
t._bind_pump(lambda: False)
|
||||
|
||||
mux.push(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": ["subgraph"],
|
||||
"timestamp": TS,
|
||||
"data": (
|
||||
{"event": "message-start", "message_id": "run-x"},
|
||||
{"run_id": "run-x"},
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
t._log.close()
|
||||
assert list(t._log._items) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle: finalize / fail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_fail_propagates_to_open_streams(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
)
|
||||
)
|
||||
streams = list(log._items)
|
||||
err = RuntimeError("graph died")
|
||||
t.fail(err)
|
||||
assert t._by_run == {}
|
||||
assert streams[0]._error is err
|
||||
|
||||
def test_finalize_clears_routing_state(self) -> None:
|
||||
t, _ = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
)
|
||||
)
|
||||
assert "run-1" in t._by_run
|
||||
t.finalize()
|
||||
assert t._by_run == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async mode (AsyncChatModelStream)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncMode:
|
||||
def test_async_mode_creates_async_stream(self) -> None:
|
||||
t, log = _make_async_transformer()
|
||||
for evt in _lifecycle(text="async stream"):
|
||||
t.process(_proto_event(evt))
|
||||
streams = list(log._items)
|
||||
assert len(streams) == 1
|
||||
assert isinstance(streams[0], AsyncChatModelStream)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_text_projection_yields_deltas(self) -> None:
|
||||
t, log = _make_async_transformer()
|
||||
for evt in _lifecycle(text="hello world"):
|
||||
t.process(_proto_event(evt))
|
||||
(stream,) = list(log._items)
|
||||
assert isinstance(stream, AsyncChatModelStream)
|
||||
collected = []
|
||||
async for delta in stream.text:
|
||||
collected.append(delta)
|
||||
assert "".join(collected) == "hello world"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_output_awaitable(self) -> None:
|
||||
t, log = _make_async_transformer()
|
||||
for evt in _lifecycle(text="async"):
|
||||
t.process(_proto_event(evt))
|
||||
(stream,) = list(log._items)
|
||||
msg = await stream.output
|
||||
assert msg.text == "async"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GraphRunStream integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWireRequestMore:
|
||||
def test_bind_pump_called_on_wire(self) -> None:
|
||||
values_t = ValuesTransformer()
|
||||
messages_t = MessagesTransformer()
|
||||
mux = StreamMux([values_t, messages_t], is_async=False)
|
||||
|
||||
assert messages_t._pump_fn is None
|
||||
run = GraphRunStream(iter([]), mux)
|
||||
# After wire, the transformer's pump callback is set.
|
||||
assert messages_t._pump_fn is not None
|
||||
# And calling it invokes GraphRunStream._pump_next (drains an empty
|
||||
# graph_iter, returns False).
|
||||
assert messages_t._pump_fn() is False
|
||||
assert run._exhausted
|
||||
|
||||
def test_created_streams_have_request_more(self) -> None:
|
||||
values_t = ValuesTransformer()
|
||||
messages_t = MessagesTransformer()
|
||||
mux = StreamMux([values_t, messages_t], is_async=False)
|
||||
|
||||
GraphRunStream(iter([]), mux)
|
||||
log: EventLog[ChatModelStream] = mux.extensions["messages"]
|
||||
log._subscribed = True
|
||||
|
||||
for evt in _lifecycle():
|
||||
messages_t.process(_proto_event(evt))
|
||||
|
||||
(stream,) = list(log._items)
|
||||
# Pump was threaded through: the stream's _request_more points at
|
||||
# the same callable the transformer was bound with.
|
||||
assert stream._request_more is messages_t._pump_fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end via StreamMux
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestViaMux:
|
||||
def test_streaming_via_mux(self) -> None:
|
||||
t = MessagesTransformer()
|
||||
v = ValuesTransformer()
|
||||
mux = StreamMux([v, t], is_async=False)
|
||||
t._bind_pump(lambda: False)
|
||||
log: EventLog[ChatModelStream] = mux.extensions["messages"]
|
||||
# Simulate a consumer subscribing (as `run.messages` iteration would).
|
||||
log._subscribed = True
|
||||
|
||||
for evt in _lifecycle(text="mux stream"):
|
||||
mux.push(_proto_event(evt))
|
||||
mux.close()
|
||||
|
||||
(stream,) = list(log._items)
|
||||
assert stream.output.text == "mux stream"
|
||||
|
||||
def test_whole_message_via_mux(self) -> None:
|
||||
t = MessagesTransformer()
|
||||
v = ValuesTransformer()
|
||||
mux = StreamMux([v, t], is_async=False)
|
||||
t._bind_pump(lambda: False)
|
||||
log: EventLog[ChatModelStream] = mux.extensions["messages"]
|
||||
log._subscribed = True
|
||||
|
||||
mux.push(_whole_msg("result"))
|
||||
mux.close()
|
||||
|
||||
(stream,) = list(log._items)
|
||||
assert stream.output.text == "result"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_streaming_via_mux(self) -> None:
|
||||
t = MessagesTransformer()
|
||||
v = ValuesTransformer()
|
||||
mux = StreamMux([v, t], is_async=True)
|
||||
log: EventLog[ChatModelStream] = mux.extensions["messages"]
|
||||
log._subscribed = True
|
||||
|
||||
for evt in _lifecycle(text="async mux"):
|
||||
await mux.apush(_proto_event(evt))
|
||||
|
||||
streams = list(log._items)
|
||||
assert len(streams) == 1
|
||||
msg = await streams[0].output
|
||||
assert msg.text == "async mux"
|
||||
await mux.aclose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: full graph → stream_v2 → run.messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
"""Prove the full pipeline works when a node calls `model.stream_v2()`.
|
||||
|
||||
These tests exercise the path that the new messages projection is
|
||||
designed for: a user node invokes `stream_v2` on a chat model,
|
||||
`on_stream_event` fires on `StreamMessagesHandler`, the handler
|
||||
forwards to the mux, and the transformer routes events into a
|
||||
`ChatModelStream` exposed on `run.messages`.
|
||||
|
||||
Nothing in Pregel calls `stream_v2` automatically yet; the planned
|
||||
`graph.stream_v2()` API (B4) and the `create_react_agent`
|
||||
integration (C2) will wire that up. Until then, populating the
|
||||
messages projection is opt-in at the node level.
|
||||
"""
|
||||
|
||||
def test_node_calling_stream_v2_populates_messages(self) -> None:
|
||||
model = GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = model.stream_v2(state["messages"])
|
||||
return {"messages": stream.output}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "hi"})
|
||||
streams = list(run.messages)
|
||||
|
||||
assert len(streams) == 1
|
||||
assert isinstance(streams[0], ChatModelStream)
|
||||
assert streams[0].output.text == "hello world"
|
||||
|
||||
def test_node_stream_v2_text_deltas_iterate(self) -> None:
|
||||
"""Consumer can iterate `.text` on the streamed message in real time."""
|
||||
model = GenericFakeChatModel(messages=iter(["streamed answer"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = model.stream_v2(state["messages"])
|
||||
return {"messages": stream.output}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "go"})
|
||||
|
||||
# Pull the stream handle out, then iterate its text deltas.
|
||||
(stream,) = list(run.messages)
|
||||
text = "".join(stream.text)
|
||||
assert text == "streamed answer"
|
||||
|
||||
def test_non_llm_message_returned_from_node(self) -> None:
|
||||
"""Node returns a finalized AIMessage directly — whole-message fallback."""
|
||||
|
||||
def return_message(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": AIMessage(content="hardcoded", id="msg-abc")}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("return_message", return_message)
|
||||
.add_edge(START, "return_message")
|
||||
.add_edge("return_message", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "hi"})
|
||||
streams = list(run.messages)
|
||||
|
||||
assert len(streams) == 1
|
||||
assert streams[0].output.text == "hardcoded"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_node_calling_astream_v2(self) -> None:
|
||||
model = GenericFakeChatModel(messages=iter(["async answer"]))
|
||||
|
||||
async def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = await model.astream_v2(state["messages"])
|
||||
msg = await stream
|
||||
return {"messages": msg}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = await graph.astream_v2({"messages": "hi"})
|
||||
|
||||
streams = []
|
||||
async for stream in run.messages:
|
||||
streams.append(stream)
|
||||
|
||||
assert len(streams) == 1
|
||||
assert isinstance(streams[0], AsyncChatModelStream)
|
||||
msg = await streams[0].output
|
||||
assert msg.text == "async answer"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_async_iteration_yields_text_deltas(self) -> None:
|
||||
"""Iterate `stream.text` inside `async for stream in run.messages`.
|
||||
|
||||
The inner `stream.text` cursor drives the shared graph pump via
|
||||
`AsyncProjection._arequest_more`, wired by
|
||||
`MessagesTransformer._bind_apump` and
|
||||
`AsyncGraphRunStream._wire_arequest_more`.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
model = GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
|
||||
async def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = await model.astream_v2(state["messages"])
|
||||
msg = await stream
|
||||
return {"messages": msg}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = await graph.astream_v2({"messages": "hi"})
|
||||
|
||||
async def consume_nested() -> list[str]:
|
||||
collected: list[str] = []
|
||||
async for stream in run.messages:
|
||||
async for delta in stream.text:
|
||||
collected.append(delta)
|
||||
return collected
|
||||
|
||||
deltas = await asyncio.wait_for(consume_nested(), timeout=2.0)
|
||||
assert "".join(deltas) == "hello world"
|
||||
|
||||
|
||||
class TestEndToEndV2Invoke:
|
||||
"""Nodes call `model.invoke()`; `stream_v2` routes through v2.
|
||||
|
||||
Exercises the auto-routing path added in
|
||||
`feat(core): route invoke through v2 event path for
|
||||
_V2StreamingCallbackHandler`: `stream_v2` injects
|
||||
`CONFIG_KEY_STREAM_MESSAGES_V2` into the config, pregel attaches
|
||||
`StreamMessagesHandlerV2`, `BaseChatModel._should_stream_v2` sees the
|
||||
v2 marker and drives the protocol event generator, and
|
||||
`on_stream_event` forwards each event onto the messages channel.
|
||||
"""
|
||||
|
||||
def test_invoke_with_v2_marker_populates_messages(self) -> None:
|
||||
"""Node calling `model.invoke()` produces one ChatModelStream with v2 events."""
|
||||
model = GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "hi"})
|
||||
streams = list(run.messages)
|
||||
|
||||
assert len(streams) == 1, (
|
||||
"Expected exactly one ChatModelStream — the streamed invoke and "
|
||||
"the node's return of the same AIMessage must dedupe."
|
||||
)
|
||||
stream = streams[0]
|
||||
assert isinstance(stream, ChatModelStream)
|
||||
assert stream.output.text == "hello world"
|
||||
|
||||
def test_invoke_v2_emits_protocol_events(self) -> None:
|
||||
"""Iterating the stream yields the full v2 lifecycle (not v1 chunks)."""
|
||||
model = GenericFakeChatModel(messages=iter(["streamed answer"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "go"})
|
||||
(stream,) = list(run.messages)
|
||||
|
||||
events = list(stream)
|
||||
event_types = [e.get("event") for e in events]
|
||||
assert "message-start" in event_types
|
||||
assert "content-block-start" in event_types
|
||||
assert "content-block-delta" in event_types
|
||||
assert "content-block-finish" in event_types
|
||||
assert "message-finish" in event_types
|
||||
# Sanity: every event is a dict carrying an "event" key — not an
|
||||
# AIMessageChunk tuple from the v1 path.
|
||||
for event in events:
|
||||
assert isinstance(event, dict)
|
||||
assert "event" in event
|
||||
# Typed projection still assembles the final text.
|
||||
assert stream.output.text == "streamed answer"
|
||||
|
||||
def test_invoke_text_deltas_iterate_live(self) -> None:
|
||||
"""`.text` projection yields deltas in order."""
|
||||
model = GenericFakeChatModel(messages=iter(["delta streaming works"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "hi"})
|
||||
(stream,) = list(run.messages)
|
||||
|
||||
assembled = "".join(stream.text)
|
||||
assert assembled == "delta streaming works"
|
||||
|
||||
def test_invoke_dedupe_survives_multi_node_graph(self) -> None:
|
||||
"""Two model-invoking nodes produce exactly two streams, each once."""
|
||||
model_a = GenericFakeChatModel(messages=iter(["alpha"]))
|
||||
model_b = GenericFakeChatModel(messages=iter(["beta"]))
|
||||
|
||||
def node_a(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model_a.invoke(state["messages"])}
|
||||
|
||||
def node_b(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model_b.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("node_a", node_a)
|
||||
.add_node("node_b", node_b)
|
||||
.add_edge(START, "node_a")
|
||||
.add_edge("node_a", "node_b")
|
||||
.add_edge("node_b", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "hi"})
|
||||
streams = list(run.messages)
|
||||
|
||||
assert len(streams) == 2
|
||||
contents = {s.output.text for s in streams}
|
||||
assert contents == {"alpha", "beta"}
|
||||
|
||||
def test_invoke_plus_constructed_message_two_streams(self) -> None:
|
||||
"""A v2-streamed node + a node that returns a constructed AIMessage
|
||||
produces two ChatModelStreams — one from the live event lifecycle,
|
||||
one synthesized from the constructed message via `message_to_events`.
|
||||
"""
|
||||
model = GenericFakeChatModel(messages=iter(["live stream"]))
|
||||
|
||||
def streaming_node(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
def constructed_node(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": [AIMessage(content="hardcoded", id="constructed-1")]}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("streaming_node", streaming_node)
|
||||
.add_node("constructed_node", constructed_node)
|
||||
.add_edge(START, "streaming_node")
|
||||
.add_edge("streaming_node", "constructed_node")
|
||||
.add_edge("constructed_node", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_v2({"messages": "hi"})
|
||||
streams = list(run.messages)
|
||||
|
||||
assert len(streams) == 2
|
||||
assert streams[0].node == "streaming_node"
|
||||
assert streams[0].output.text == "live stream"
|
||||
assert streams[1].node == "constructed_node"
|
||||
assert streams[1].output.text == "hardcoded"
|
||||
assert streams[1].message_id == "constructed-1"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ainvoke_with_v2_marker_populates_messages(self) -> None:
|
||||
"""Async mirror: `model.ainvoke()` + `astream_v2`."""
|
||||
model = GenericFakeChatModel(messages=iter(["async invoke"]))
|
||||
|
||||
async def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": await model.ainvoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = await graph.astream_v2({"messages": "hi"})
|
||||
|
||||
streams = []
|
||||
async for stream in run.messages:
|
||||
streams.append(stream)
|
||||
|
||||
assert len(streams) == 1
|
||||
assert isinstance(streams[0], AsyncChatModelStream)
|
||||
msg = await streams[0].output
|
||||
assert msg.text == "async invoke"
|
||||
|
||||
|
||||
class TestDirectMessagesModeStaysV1:
|
||||
"""Regression guard: direct `graph.stream(stream_mode="messages")`
|
||||
(no `stream_v2`) must keep the v1 `(AIMessageChunk, metadata)`
|
||||
tuple shape. The v2 flag is only injected by `stream_v2` / `astream_v2`.
|
||||
"""
|
||||
|
||||
def test_direct_graph_stream_messages_yields_ai_message_chunks(self) -> None:
|
||||
model = GenericFakeChatModel(messages=iter(["legacy path"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
parts = list(graph.stream({"messages": "hi"}, stream_mode="messages"))
|
||||
# Should have at least one streamed chunk; each part is
|
||||
# (AIMessageChunk, metadata) — not a v2 event dict.
|
||||
assert parts, "expected stream_mode='messages' to emit tuples"
|
||||
for part in parts:
|
||||
payload, _metadata = part
|
||||
assert isinstance(payload, AIMessageChunk), (
|
||||
"direct graph.stream(stream_mode='messages') leaked v2 "
|
||||
"event dicts — stream_v2 flag bled through."
|
||||
)
|
||||
assembled = "".join(
|
||||
p[0].content for p in parts if isinstance(p[0].content, str)
|
||||
)
|
||||
assert assembled == "legacy path"
|
||||
|
||||
|
||||
class TestStreamMessagesHandlerV2Unit:
|
||||
"""Unit tests on the handler class itself."""
|
||||
|
||||
def test_on_llm_new_token_is_noop(self) -> None:
|
||||
"""v2 handler must not emit v1 chunks even if `on_llm_new_token` fires
|
||||
(e.g. from a node calling `model.stream()` directly on a v2-flagged run).
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.outputs import ChatGenerationChunk
|
||||
|
||||
from langgraph.pregel._messages import StreamMessagesHandlerV2
|
||||
|
||||
emitted: list[Any] = []
|
||||
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
|
||||
run_id = uuid4()
|
||||
# Register a fake run so `self.metadata.get(run_id)` would succeed for
|
||||
# other callbacks — this makes sure the no-op is unconditional, not a
|
||||
# side effect of missing metadata.
|
||||
handler.metadata[run_id] = ((), {"langgraph_node": "x"})
|
||||
|
||||
handler.on_llm_new_token(
|
||||
"hello",
|
||||
chunk=ChatGenerationChunk(message=AIMessageChunk(content="hello")),
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
assert emitted == [], (
|
||||
"StreamMessagesHandlerV2.on_llm_new_token must not push to the "
|
||||
"messages stream — it's the v2 marker's guarantee."
|
||||
)
|
||||
@@ -1,293 +0,0 @@
|
||||
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, StreamTransformer
|
||||
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(StreamTransformer):
|
||||
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(StreamTransformer):
|
||||
"""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."
|
||||
)
|
||||
@@ -1,161 +0,0 @@
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_values_captures_values_events():
|
||||
reducer = ValuesTransformer()
|
||||
reducer.init()
|
||||
reducer.process(_event("values", {"a": 1}))
|
||||
reducer.process(_event("values", {"b": 2}))
|
||||
reducer.finalize()
|
||||
|
||||
assert len(reducer.values_log) == 2
|
||||
assert reducer.values_log[0]["data"] == {"a": 1}
|
||||
assert reducer.values_log[1]["data"] == {"b": 2}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
assert len(reducer.values_log) == 0
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
# -- 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,
|
||||
)
|
||||
|
||||
|
||||
def test_messages_groups_lifecycle():
|
||||
reducer = MessagesTransformer()
|
||||
reducer.init()
|
||||
reducer.process(_msg_start())
|
||||
reducer.process(_content_delta("hi"))
|
||||
reducer.process(_msg_finish())
|
||||
reducer.finalize()
|
||||
|
||||
assert len(reducer.messages_log) == 1
|
||||
assert isinstance(reducer.messages_log[0], ChatModelStream)
|
||||
assert reducer.messages_log[0].done
|
||||
|
||||
|
||||
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()
|
||||
|
||||
assert len(reducer.messages_log) == 2
|
||||
|
||||
|
||||
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()
|
||||
|
||||
assert len(reducer.messages_log) == 1
|
||||
|
||||
|
||||
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()
|
||||
|
||||
assert len(reducer.messages_log) == 1
|
||||
|
||||
|
||||
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()
|
||||
|
||||
assert len(reducer.messages_log) == 1
|
||||
assert reducer.messages_log[0].done
|
||||
|
||||
|
||||
def test_messages_fail_propagates_to_active():
|
||||
"""transformer.fail() should mark active streams as done."""
|
||||
reducer = MessagesTransformer()
|
||||
reducer.init()
|
||||
reducer.process(_msg_start())
|
||||
reducer.process(_content_delta("partial"))
|
||||
reducer.fail(RuntimeError("graph failed"))
|
||||
|
||||
assert len(reducer.messages_log) == 1
|
||||
assert reducer.messages_log[0].done
|
||||
@@ -1,610 +0,0 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from langgraph.stream._mux import AsyncStreamMux, StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
from langgraph.stream.chat_model_stream import ChatModelStream
|
||||
from langgraph.stream.run_stream import (
|
||||
AsyncGraphRunStream,
|
||||
AsyncSubgraphRunStream,
|
||||
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_accessible():
|
||||
"""Sync consumers should be able to read ChatModelStream text content
|
||||
without an async event loop.
|
||||
"""
|
||||
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"}),
|
||||
]
|
||||
run = create_graph_run_stream(_sync_source(chunks))
|
||||
|
||||
for msg in run.messages:
|
||||
assert isinstance(msg.text, str)
|
||||
assert msg.text == "Hello world"
|
||||
assert msg.done
|
||||
|
||||
|
||||
def test_sync_messages_content_populated_when_yielded():
|
||||
"""When sync run.messages yields a ChatModelStream, its content should
|
||||
be fully populated (done=True) with all text accumulated.
|
||||
"""
|
||||
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))
|
||||
|
||||
messages = list(run.messages)
|
||||
assert len(messages) == 2
|
||||
assert messages[0].done
|
||||
assert messages[0].text == "answer"
|
||||
assert messages[1].done
|
||||
assert messages[1].text == "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 — pumps until message-finish (item 3) so the
|
||||
# ChatModelStream is fully populated before yielding.
|
||||
mit = iter(run.messages)
|
||||
msg = next(mit)
|
||||
assert isinstance(msg, ChatModelStream)
|
||||
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."
|
||||
)
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Tests for SubgraphTransformer namespace-based discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.stream._event_log import EventLog
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
from langgraph.stream.transformers import (
|
||||
MessagesTransformer,
|
||||
SubgraphRunStream,
|
||||
SubgraphTransformer,
|
||||
ValuesTransformer,
|
||||
)
|
||||
from langgraph.types import interrupt
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _values(payload: dict[str, Any], *, namespace: list[str]) -> ProtocolEvent:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "values",
|
||||
"params": {
|
||||
"namespace": namespace,
|
||||
"timestamp": TS,
|
||||
"data": payload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _subscribe(log: EventLog) -> None:
|
||||
"""Flip `_subscribed = True` so pushes retain items for test inspection."""
|
||||
log._subscribed = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: feed events directly into the transformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_FACTORIES = [ValuesTransformer, MessagesTransformer, SubgraphTransformer]
|
||||
|
||||
|
||||
def _handle_values_items(handle: SubgraphRunStream) -> list:
|
||||
return list(handle._mux.extensions["values"]._items) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _handle_subgraphs_items(handle: SubgraphRunStream) -> list:
|
||||
return list(handle._mux.extensions["subgraphs"]._items) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _pre_subscribe_handle(handle: SubgraphRunStream) -> None:
|
||||
"""Flip `_subscribed` on every EventLog inside the handle's mini-mux.
|
||||
|
||||
The mini-mux is built via `make_child` with the full factory list,
|
||||
so values / messages / subgraphs logs all exist as projections.
|
||||
Tests that feed events directly need them subscribed so pushes
|
||||
retain items in the deque for `_items` inspection.
|
||||
"""
|
||||
for value in handle._mux.extensions.values():
|
||||
if isinstance(value, EventLog):
|
||||
_subscribe(value)
|
||||
|
||||
|
||||
class TestSubgraphTransformerUnit:
|
||||
def _mux(self) -> tuple[StreamMux, SubgraphTransformer]:
|
||||
mux = StreamMux(factories=_FACTORIES, is_async=False)
|
||||
transformer = mux.transformer_by_key("subgraphs")
|
||||
assert isinstance(transformer, SubgraphTransformer)
|
||||
_subscribe(transformer._root_log)
|
||||
return mux, transformer
|
||||
|
||||
def _handle(self, transformer: SubgraphTransformer) -> SubgraphRunStream:
|
||||
(handle,) = list(transformer._root_log._items)
|
||||
return handle
|
||||
|
||||
def test_root_event_does_not_create_handle(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=[]))
|
||||
assert list(transformer._root_log._items) == []
|
||||
assert transformer._by_ns == {}
|
||||
|
||||
def test_first_event_at_child_depth_yields_handle(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["child:task_a"]))
|
||||
|
||||
handle = self._handle(transformer)
|
||||
assert handle.path == ("child:task_a",)
|
||||
assert handle.graph_name == "child"
|
||||
assert handle.trigger_call_id == "task_a"
|
||||
assert handle.status == "started"
|
||||
|
||||
def test_handle_without_task_id_suffix(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["child"]))
|
||||
|
||||
handle = self._handle(transformer)
|
||||
assert handle.path == ("child",)
|
||||
assert handle.graph_name == "child"
|
||||
assert handle.trigger_call_id is None
|
||||
|
||||
def test_discovery_is_method_agnostic(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
# Method-agnostic means "any event method triggers discovery".
|
||||
# Use `updates` — neither ValuesTransformer nor
|
||||
# MessagesTransformer care about it, so the test only exercises
|
||||
# SubgraphTransformer's discovery path.
|
||||
mux.push(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "updates",
|
||||
"params": {"namespace": ["c:t"], "timestamp": TS, "data": "x"},
|
||||
}
|
||||
)
|
||||
handle = self._handle(transformer)
|
||||
assert handle.path == ("c:t",)
|
||||
|
||||
def test_grandchild_surfaces_under_child(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["child:t"]))
|
||||
|
||||
child = self._handle(transformer)
|
||||
_pre_subscribe_handle(child)
|
||||
|
||||
mux.push(_values({"value": 2}, namespace=["child:t", "grand:u"]))
|
||||
|
||||
(grand,) = _handle_subgraphs_items(child)
|
||||
assert grand.path == ("child:t", "grand:u")
|
||||
assert grand.graph_name == "grand"
|
||||
assert grand.trigger_call_id == "u"
|
||||
|
||||
def test_values_routed_into_handle(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["c:t"]))
|
||||
|
||||
handle = self._handle(transformer)
|
||||
_pre_subscribe_handle(handle)
|
||||
|
||||
mux.push(_values({"value": 2}, namespace=["c:t"]))
|
||||
|
||||
# Both the discovery event and subsequent values land in the child.
|
||||
assert _handle_values_items(handle) == [{"value": 1}, {"value": 2}]
|
||||
assert handle.output == {"value": 2}
|
||||
|
||||
def test_root_values_not_routed(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["c:t"]))
|
||||
handle = self._handle(transformer)
|
||||
_pre_subscribe_handle(handle)
|
||||
|
||||
# Values event at root namespace — must not leak into child handle.
|
||||
mux.push(_values({"value": "root"}, namespace=[]))
|
||||
assert _handle_values_items(handle) == [{"value": 1}]
|
||||
|
||||
def test_finalize_closes_dangling(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["c:t"]))
|
||||
handle = self._handle(transformer)
|
||||
|
||||
mux.close()
|
||||
assert handle.status == "completed"
|
||||
assert handle._mux.extensions["values"]._closed
|
||||
assert handle._mux.extensions["subgraphs"]._closed
|
||||
|
||||
def test_fail_with_graph_interrupt_marks_interrupted(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["c:t"]))
|
||||
handle = self._handle(transformer)
|
||||
|
||||
mux.fail(GraphInterrupt())
|
||||
assert handle.status == "interrupted"
|
||||
|
||||
def test_fail_with_generic_error_marks_failed(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["c:t"]))
|
||||
handle = self._handle(transformer)
|
||||
|
||||
mux.fail(RuntimeError("explode"))
|
||||
assert handle.status == "failed"
|
||||
assert handle.error == "explode"
|
||||
|
||||
def test_repeated_events_same_ns_single_handle(self) -> None:
|
||||
mux, transformer = self._mux()
|
||||
mux.push(_values({"value": 1}, namespace=["c:t"]))
|
||||
mux.push(_values({"value": 2}, namespace=["c:t"]))
|
||||
mux.push(_values({"value": 3}, namespace=["c:t"]))
|
||||
|
||||
handles = list(transformer._root_log._items)
|
||||
assert len(handles) == 1
|
||||
assert handles[0].path == ("c:t",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end tests via stream_v2 on real graphs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SimpleState(TypedDict):
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _build_nested_graph():
|
||||
"""Parent graph with a compiled subgraph node."""
|
||||
|
||||
def inner_node(state: SimpleState) -> dict:
|
||||
return {"value": state["value"] + "X", "items": ["x"]}
|
||||
|
||||
inner_builder = StateGraph(SimpleState)
|
||||
inner_builder.add_node("inner_node", inner_node)
|
||||
inner_builder.add_edge(START, "inner_node")
|
||||
inner_builder.add_edge("inner_node", END)
|
||||
inner = inner_builder.compile()
|
||||
|
||||
def outer_node(state: SimpleState) -> dict:
|
||||
return {"value": state["value"] + "Y", "items": ["y"]}
|
||||
|
||||
outer_builder = StateGraph(SimpleState)
|
||||
outer_builder.add_node("outer_node", outer_node)
|
||||
outer_builder.add_node("sub", inner)
|
||||
outer_builder.add_edge(START, "outer_node")
|
||||
outer_builder.add_edge("outer_node", "sub")
|
||||
outer_builder.add_edge("sub", END)
|
||||
return outer_builder.compile()
|
||||
|
||||
|
||||
class TestSubgraphTransformerEndToEnd:
|
||||
def test_flat_graph_yields_no_subgraphs(self) -> None:
|
||||
builder = StateGraph(SimpleState)
|
||||
builder.add_node("n", lambda s: {"value": s["value"] + "!", "items": ["!"]})
|
||||
builder.add_edge(START, "n")
|
||||
builder.add_edge("n", END)
|
||||
graph = builder.compile()
|
||||
|
||||
run = graph.stream_v2({"value": "", "items": []})
|
||||
|
||||
collected: list[SubgraphRunStream] = []
|
||||
for sub in run.subgraphs:
|
||||
collected.append(sub)
|
||||
assert collected == []
|
||||
# Output still resolves.
|
||||
assert run.output is not None
|
||||
|
||||
def test_nested_graph_yields_one_child(self) -> None:
|
||||
graph = _build_nested_graph()
|
||||
run = graph.stream_v2({"value": "", "items": []})
|
||||
|
||||
collected: list[SubgraphRunStream] = []
|
||||
for sub in run.subgraphs:
|
||||
collected.append(sub)
|
||||
|
||||
assert len(collected) == 1
|
||||
child = collected[0]
|
||||
assert len(child.path) == 1
|
||||
assert child.path[0].startswith("sub:")
|
||||
assert child.status == "completed"
|
||||
|
||||
def test_error_in_subgraph_fails_child(self) -> None:
|
||||
def boom(state: SimpleState) -> dict:
|
||||
raise RuntimeError("subgraph_failed")
|
||||
|
||||
inner_builder = StateGraph(SimpleState)
|
||||
inner_builder.add_node("inner", boom)
|
||||
inner_builder.add_edge(START, "inner")
|
||||
inner_builder.add_edge("inner", END)
|
||||
inner = inner_builder.compile()
|
||||
|
||||
outer_builder = StateGraph(SimpleState)
|
||||
outer_builder.add_node("sub", inner)
|
||||
outer_builder.add_edge(START, "sub")
|
||||
outer_builder.add_edge("sub", END)
|
||||
graph = outer_builder.compile()
|
||||
|
||||
run = graph.stream_v2({"value": "", "items": []})
|
||||
|
||||
collected: list[SubgraphRunStream] = []
|
||||
with pytest.raises(RuntimeError):
|
||||
for sub in run.subgraphs:
|
||||
collected.append(sub)
|
||||
|
||||
assert len(collected) == 1
|
||||
assert collected[0].status == "failed"
|
||||
|
||||
|
||||
class TestSubgraphTransformerAsyncEndToEnd:
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_graph_yields_one_child(self) -> None:
|
||||
async def inner(state: SimpleState) -> dict:
|
||||
return {"value": state["value"] + "X", "items": ["x"]}
|
||||
|
||||
inner_builder = StateGraph(SimpleState)
|
||||
inner_builder.add_node("inner", inner)
|
||||
inner_builder.add_edge(START, "inner")
|
||||
inner_builder.add_edge("inner", END)
|
||||
inner_graph = inner_builder.compile()
|
||||
|
||||
outer_builder = StateGraph(SimpleState)
|
||||
outer_builder.add_node("sub", inner_graph)
|
||||
outer_builder.add_edge(START, "sub")
|
||||
outer_builder.add_edge("sub", END)
|
||||
graph = outer_builder.compile()
|
||||
|
||||
run = await graph.astream_v2({"value": "", "items": []})
|
||||
|
||||
collected: list[SubgraphRunStream] = []
|
||||
async for sub in run.subgraphs:
|
||||
collected.append(sub)
|
||||
|
||||
assert len(collected) == 1
|
||||
child = collected[0]
|
||||
assert child.status == "completed"
|
||||
|
||||
|
||||
class TestSubgraphTriggerCallId:
|
||||
"""Confirm `trigger_call_id` flows from real pregel metadata."""
|
||||
|
||||
def test_trigger_call_id_populated_end_to_end(self) -> None:
|
||||
graph = _build_nested_graph()
|
||||
run = graph.stream_v2({"value": "", "items": []})
|
||||
|
||||
collected: list[SubgraphRunStream] = list(run.subgraphs)
|
||||
assert len(collected) == 1
|
||||
child = collected[0]
|
||||
|
||||
# The child's single-segment path encodes `node_name:task_id`.
|
||||
# Both the parsed task_id (`trigger_call_id`) and the segment
|
||||
# should match the same task_id suffix.
|
||||
assert ":" in child.path[0]
|
||||
node_name, _, task_id = child.path[0].partition(":")
|
||||
assert node_name == "sub"
|
||||
assert task_id # non-empty
|
||||
assert child.trigger_call_id == task_id
|
||||
|
||||
|
||||
class TestSubgraphInterrupt:
|
||||
"""Interrupts raised inside a subgraph surface as status=interrupted."""
|
||||
|
||||
def _build_interrupt_subgraph(self):
|
||||
def inner_node(state: SimpleState) -> dict:
|
||||
interrupt("need approval")
|
||||
return {"value": state["value"] + "X", "items": ["x"]}
|
||||
|
||||
inner_builder = StateGraph(SimpleState)
|
||||
inner_builder.add_node("inner_node", inner_node)
|
||||
inner_builder.add_edge(START, "inner_node")
|
||||
inner_builder.add_edge("inner_node", END)
|
||||
inner = inner_builder.compile()
|
||||
|
||||
outer_builder = StateGraph(SimpleState)
|
||||
outer_builder.add_node("sub", inner)
|
||||
outer_builder.add_edge(START, "sub")
|
||||
outer_builder.add_edge("sub", END)
|
||||
return outer_builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
def test_interrupt_in_subgraph_marks_handle_interrupted(self) -> None:
|
||||
graph = self._build_interrupt_subgraph()
|
||||
run = graph.stream_v2(
|
||||
{"value": "", "items": []},
|
||||
config={"configurable": {"thread_id": "t1"}},
|
||||
)
|
||||
|
||||
collected: list[SubgraphRunStream] = list(run.subgraphs)
|
||||
|
||||
assert run.interrupted is True
|
||||
assert len(collected) == 1
|
||||
assert collected[0].status == "interrupted"
|
||||
|
||||
|
||||
class TestSubgraphNameCollision:
|
||||
"""The subgraph's compiled `name` equaling its node name is detected.
|
||||
|
||||
Primary detector `name != langgraph_node` fails here; the
|
||||
parent_run_id fallback in `_is_nested_pregel_start` is what keeps
|
||||
the subgraph visible.
|
||||
"""
|
||||
|
||||
def test_name_equals_node_name_still_detected(self) -> None:
|
||||
def inner_node(state: SimpleState) -> dict:
|
||||
return {"value": state["value"] + "X", "items": ["x"]}
|
||||
|
||||
inner_builder = StateGraph(SimpleState)
|
||||
inner_builder.add_node("inner_node", inner_node)
|
||||
inner_builder.add_edge(START, "inner_node")
|
||||
inner_builder.add_edge("inner_node", END)
|
||||
# Compile with the same name as the node it will be registered as.
|
||||
inner = inner_builder.compile(name="sub")
|
||||
|
||||
outer_builder = StateGraph(SimpleState)
|
||||
outer_builder.add_node("sub", inner)
|
||||
outer_builder.add_edge(START, "sub")
|
||||
outer_builder.add_edge("sub", END)
|
||||
graph = outer_builder.compile()
|
||||
|
||||
run = graph.stream_v2({"value": "", "items": []})
|
||||
|
||||
collected: list[SubgraphRunStream] = list(run.subgraphs)
|
||||
assert len(collected) == 1
|
||||
child = collected[0]
|
||||
assert child.graph_name == "sub"
|
||||
assert child.status == "completed"
|
||||
@@ -1,420 +0,0 @@
|
||||
"""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
|
||||
@@ -11,13 +11,19 @@ from typing import (
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, 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
|
||||
from langgraph._internal._config import (
|
||||
_is_not_empty,
|
||||
ensure_config,
|
||||
get_callback_manager_for_config,
|
||||
)
|
||||
from langgraph._internal._fields import (
|
||||
_is_optional_type,
|
||||
get_enhanced_type_hints,
|
||||
@@ -298,7 +304,7 @@ def test_is_not_empty() -> None:
|
||||
assert not _is_not_empty({})
|
||||
|
||||
|
||||
def test_configurable_metadata():
|
||||
def test_configurable_metadata() -> None:
|
||||
config = {
|
||||
"configurable": {
|
||||
"a-key": "foo",
|
||||
@@ -309,11 +315,115 @@ def test_configurable_metadata():
|
||||
"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 metadata.keys() == expected
|
||||
assert set(metadata) == {
|
||||
"nooverride",
|
||||
"assistant_id",
|
||||
"thread_id",
|
||||
"checkpoint_id",
|
||||
"run_id",
|
||||
"graph_id",
|
||||
"checkpoint_ns",
|
||||
"task_id",
|
||||
}
|
||||
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",
|
||||
}
|
||||
|
||||
Generated
+21
-8
@@ -1348,10 +1348,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.22"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -1360,14 +1361,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/bb/38b5eaefa41c67735eedd9f9a2568b11c9eb376fa129a5edd7cc3dcde071/langchain_protocol-0.0.11.tar.gz", hash = "sha256:c276e2373b5ac691fc7ac9a72019d55182444ce8e89385c3f7e9f0185d0aace7", size = 6622, upload-time = "2026-04-23T22:13:16.771Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/fa/6a8ecad8472b182f2caf9d83fd89f40fc1590cb96546d90089b7869b7f5e/langchain_protocol-0.0.11-py3-none-any.whl", hash = "sha256:364da1faf6f5d3001413bede792c1a822c0f23ae55d1ce1266ca7d8e80e79011", size = 6778, upload-time = "2026-04-23T22:13:15.712Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.6"
|
||||
version = "1.1.7a2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1439,7 +1452,7 @@ test = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=0.1" },
|
||||
{ name = "langchain-core", specifier = "==1.3.2" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
@@ -2905,7 +2918,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -2916,9 +2929,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+21
-8
@@ -249,10 +249,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.25"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -261,14 +262,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/bb/38b5eaefa41c67735eedd9f9a2568b11c9eb376fa129a5edd7cc3dcde071/langchain_protocol-0.0.11.tar.gz", hash = "sha256:c276e2373b5ac691fc7ac9a72019d55182444ce8e89385c3f7e9f0185d0aace7", size = 6622, upload-time = "2026-04-23T22:13:16.771Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/fa/6a8ecad8472b182f2caf9d83fd89f40fc1590cb96546d90089b7869b7f5e/langchain_protocol-0.0.11-py3-none-any.whl", hash = "sha256:364da1faf6f5d3001413bede792c1a822c0f23ae55d1ce1266ca7d8e80e79011", size = 6778, upload-time = "2026-04-23T22:13:15.712Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.6"
|
||||
version = "1.1.7a2"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -281,7 +294,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=0.1" },
|
||||
{ name = "langchain-core", specifier = "==1.3.2" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "." },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
@@ -1182,7 +1195,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1193,9 +1206,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+21
-8
@@ -262,10 +262,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -274,14 +275,26 @@ 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/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/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/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/bb/38b5eaefa41c67735eedd9f9a2568b11c9eb376fa129a5edd7cc3dcde071/langchain_protocol-0.0.11.tar.gz", hash = "sha256:c276e2373b5ac691fc7ac9a72019d55182444ce8e89385c3f7e9f0185d0aace7", size = 6622, upload-time = "2026-04-23T22:13:16.771Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/fa/6a8ecad8472b182f2caf9d83fd89f40fc1590cb96546d90089b7869b7f5e/langchain_protocol-0.0.11-py3-none-any.whl", hash = "sha256:364da1faf6f5d3001413bede792c1a822c0f23ae55d1ce1266ca7d8e80e79011", size = 6778, upload-time = "2026-04-23T22:13:15.712Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.6"
|
||||
version = "1.1.7a2"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -294,7 +307,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=0.1" },
|
||||
{ name = "langchain-core", specifier = "==1.3.2" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
|
||||
{ name = "langgraph-sdk", editable = "." },
|
||||
@@ -1000,7 +1013,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1011,9 +1024,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user