mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-18 05:35:43 +02:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e2ff0561 | ||
|
|
e787af200e | ||
|
|
604534e1b7 | ||
|
|
346aa97425 | ||
|
|
82b3872820 | ||
|
|
fcc4ab8dd8 | ||
|
|
701d34494c | ||
|
|
2c7967ca96 | ||
|
|
bf7fec0bd1 | ||
|
|
8215a9d024 | ||
|
|
aa322c13cd | ||
|
|
d6319126ef | ||
|
|
9b0864a83c | ||
|
|
e68726d639 | ||
|
|
a45902061b | ||
|
|
5d3f11ef04 | ||
|
|
37dc5c13c8 | ||
|
|
ea44df3476 | ||
|
|
49e0ac6aa8 | ||
|
|
443bb7ba35 | ||
|
|
1c4fbb3709 | ||
|
|
02e042d58c | ||
|
|
bb6bb60688 | ||
|
|
296a83b8ff | ||
|
|
63a23ee999 | ||
|
|
ebd2f7bb59 | ||
|
|
1e8219a5c8 | ||
|
|
8411ffd98c | ||
|
|
2add40fdfa | ||
|
|
076e2a3627 | ||
|
|
4a86705bd7 |
@@ -58,14 +58,18 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Annotation
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.files.outputs.added_modified_renamed }}
|
||||
BENCHMARK_OUTPUT: ${{ steps.benchmark.outputs.OUTPUT }}
|
||||
COMPARE_OUTPUT: ${{ steps.compare.outputs.OUTPUT }}
|
||||
with:
|
||||
script: |
|
||||
const file = JSON.parse(`${{ steps.files.outputs.added_modified_renamed }}`)[0]
|
||||
core.notice(`${{ steps.benchmark.outputs.OUTPUT }}`, {
|
||||
const file = JSON.parse(process.env.CHANGED_FILES || "[]")[0]
|
||||
core.notice(process.env.BENCHMARK_OUTPUT || "", {
|
||||
title: 'Benchmark results',
|
||||
file,
|
||||
})
|
||||
core.notice(`${{ steps.compare.outputs.OUTPUT }}`, {
|
||||
core.notice(process.env.COMPARE_OUTPUT || "", {
|
||||
title: 'Comparison against main',
|
||||
file,
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache-suffix: "release"
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
@@ -268,6 +269,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache-suffix: "release"
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
@@ -309,6 +311,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache-suffix: "release"
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
|
||||
Generated
+4
-4
@@ -208,11 +208,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -279,7 +279,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Generated
+7
-7
@@ -205,11 +205,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -276,7 +276,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -399,7 +399,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -412,9 +412,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+7
-7
@@ -214,11 +214,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -285,7 +285,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -402,7 +402,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -415,9 +415,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -157,27 +157,6 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
)
|
||||
return clone
|
||||
|
||||
def _encode_constructor_args(
|
||||
self,
|
||||
constructor: Callable | type[Any],
|
||||
*,
|
||||
method: None | str | Sequence[None | str] = None,
|
||||
args: Sequence[Any] | None = None,
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
out = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": (*constructor.__module__.split("."), constructor.__name__),
|
||||
}
|
||||
if method is not None:
|
||||
out["method"] = method
|
||||
if args is not None:
|
||||
out["args"] = args
|
||||
if kwargs is not None:
|
||||
out["kwargs"] = kwargs
|
||||
return out
|
||||
|
||||
def _reviver(self, value: dict[str, Any]) -> Any:
|
||||
if (
|
||||
value.get("lc", None) == 2
|
||||
@@ -203,45 +182,46 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
self._check_allowed_json_modules(value)
|
||||
|
||||
[*module, name] = value["id"]
|
||||
# The `method` field on lc:2 envelopes is intentionally ignored.
|
||||
# Revival is restricted to the default constructor (no `getattr`
|
||||
# dispatch on attacker-influenced names). The framework's own
|
||||
# encoder has not emitted `method=` since the msgpack migration,
|
||||
# and the only legacy emission was `method=(None, "construct")`
|
||||
# for pydantic models, where the first entry (`None`) already
|
||||
# meant "default constructor", which is what we do here.
|
||||
try:
|
||||
mod = importlib.import_module(".".join(module))
|
||||
cls = getattr(mod, name)
|
||||
method = value.get("method")
|
||||
if isinstance(method, str):
|
||||
methods = [getattr(cls, method)]
|
||||
elif isinstance(method, list):
|
||||
methods = [cls if m is None else getattr(cls, m) for m in method]
|
||||
else:
|
||||
methods = [cls]
|
||||
if isclass(cls) and issubclass(cls, BaseException):
|
||||
return None
|
||||
args = value.get("args")
|
||||
kwargs = value.get("kwargs")
|
||||
for method in methods:
|
||||
try:
|
||||
if isclass(method) and issubclass(method, BaseException):
|
||||
return None
|
||||
if args and kwargs:
|
||||
return method(*args, **kwargs)
|
||||
elif args:
|
||||
return method(*args)
|
||||
elif kwargs:
|
||||
return method(**kwargs)
|
||||
else:
|
||||
return method()
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
if args and kwargs:
|
||||
return cls(*args, **kwargs)
|
||||
elif args:
|
||||
return cls(*args)
|
||||
elif kwargs:
|
||||
return cls(**kwargs)
|
||||
else:
|
||||
return cls()
|
||||
except Exception as exc:
|
||||
# Method-field dispatch has been removed (GHSA-fjqc-hq36-qh5p), so
|
||||
# legacy pydantic payloads emitting `method=[None, "construct"]`
|
||||
# no longer fall back to `cls.construct(**kwargs)` when the
|
||||
# default constructor rejects the serialized kwargs. Surface a
|
||||
# one-line warning so operators can spot payloads that now revive
|
||||
# to `None` instead of silently degrading to the raw envelope.
|
||||
logger.warning(
|
||||
"Failed to revive lc:2 envelope %s "
|
||||
"(legacy_method_field=%s, error=%s); returning None",
|
||||
".".join((*module, name)),
|
||||
"method" in value,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
def _check_allowed_json_modules(self, value: dict[str, Any]) -> None:
|
||||
needed = tuple(value["id"])
|
||||
method = value.get("method")
|
||||
if isinstance(method, list):
|
||||
method_display = ",".join(m or "<init>" for m in method)
|
||||
elif isinstance(method, str):
|
||||
method_display = method
|
||||
else:
|
||||
method_display = "<init>"
|
||||
|
||||
dotted = ".".join(needed)
|
||||
# Safe types (the same set already allowed for msgpack deserialization) are
|
||||
# permitted without an explicit allowlist — they are known-safe LangGraph and
|
||||
@@ -252,7 +232,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
|
||||
if not self._allowed_json_modules:
|
||||
raise InvalidModuleError(
|
||||
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
|
||||
f"Refused to deserialize JSON constructor: {dotted}. "
|
||||
"No allowed_json_modules configured.\n\n"
|
||||
"Unblock with ONE of:\n"
|
||||
f" • JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
|
||||
@@ -267,7 +247,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
return
|
||||
|
||||
raise InvalidModuleError(
|
||||
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
|
||||
f"Refused to deserialize JSON constructor: {dotted}. "
|
||||
"Symbol is not in the deserialization allowlist.\n\n"
|
||||
"Add exactly this symbol to unblock:\n"
|
||||
f" JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -398,6 +398,193 @@ def test_deserde_invalid_module() -> None:
|
||||
serde.loads_typed(("json", json.dumps(load).encode("utf-8")))
|
||||
|
||||
|
||||
def test_lc2_json_method_field_is_ignored() -> None:
|
||||
"""The `method` field on lc=2 envelopes is ignored.
|
||||
|
||||
Regression test for GHSA-fjqc-hq36-qh5p: `_revive_lc2` previously resolved
|
||||
`getattr(cls, method)` from the envelope, which let an attacker pivot a
|
||||
safe pydantic class (e.g., AIMessage) to `parse_raw(..., allow_pickle=True)`
|
||||
and reach `pickle.loads`. Revival now uses only the default constructor.
|
||||
|
||||
Verifies that an envelope carrying ``method="parse_raw"`` does not dispatch
|
||||
to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would
|
||||
produce, which proves the default constructor ran instead of ``parse_raw``.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": "parse_raw",
|
||||
"args": ["default-ctor-ran"],
|
||||
"kwargs": {"content_type": "application/pickle", "allow_pickle": True},
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
# Default constructor accepts `content` as first positional arg. If parse_raw
|
||||
# had been invoked instead, it would have attempted JSON/pickle parsing and
|
||||
# raised (or executed the pickle gadget); neither would produce this result.
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "default-ctor-ran"
|
||||
|
||||
|
||||
def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
|
||||
"""The `method` field is ignored even when the class is explicitly allowlisted.
|
||||
|
||||
A user who configures ``allowed_json_modules`` for a class no longer gets
|
||||
method dispatch as a side effect. Revival is restricted to the default
|
||||
constructor regardless of how the class reached the revival path.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")]
|
||||
)
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": "parse_raw",
|
||||
"args": ["default-ctor-ran"],
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "default-ctor-ran"
|
||||
|
||||
|
||||
def test_lc2_json_safe_type_init_still_works() -> None:
|
||||
"""SAFE-type lc=2 revival without a `method` field still constructs the class."""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"kwargs": {"content": "hi", "type": "ai"},
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "hi"
|
||||
|
||||
|
||||
def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None:
|
||||
"""Legacy ``method=(None, "construct")`` envelopes still revive via the default ctor.
|
||||
|
||||
Pre-October-2025 langgraph emitted pydantic models with
|
||||
``method=(None, "construct")`` meaning "try default constructor, fall back
|
||||
to pydantic ``construct``". The first entry (``None``) was always the
|
||||
default constructor, which is what we now do unconditionally. Envelopes of
|
||||
this shape continue to revive correctly as long as the default constructor
|
||||
accepts the serialized kwargs.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": [None, "construct"],
|
||||
"kwargs": {"content": "legacy", "type": "ai"},
|
||||
}
|
||||
result = serde._revive_lc2(load)
|
||||
assert isinstance(result, AIMessage)
|
||||
assert result.content == "legacy"
|
||||
|
||||
|
||||
def test_lc2_json_legacy_construct_payload_logs_warning_when_default_init_rejects(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Legacy `method=[None, "construct"]` envelopes whose kwargs the default
|
||||
`__init__` rejects now revive to `None` and emit an observable warning.
|
||||
|
||||
Pre-October-2025 langgraph emitted pydantic payloads with
|
||||
`method=[None, "construct"]` so the reviver could fall back to
|
||||
`cls.construct(**kwargs)` when the default constructor raised a
|
||||
validation error. That fallback was removed with method-field dispatch
|
||||
(GHSA-fjqc-hq36-qh5p), so these payloads now silently fail validation. A
|
||||
`logger.warning` makes the regression observable to operators instead of
|
||||
letting the envelope quietly degrade to its raw-dict form.
|
||||
"""
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
# Legacy two-entry method tuple: try default ctor, fall back to construct.
|
||||
"method": [None, "construct"],
|
||||
# ``type="not-a-real-message-type"`` fails AIMessage's Literal["ai"]
|
||||
# validator under the default constructor. Before the GHSA patch this
|
||||
# would have fallen back to ``cls.construct(**kwargs)``; now it must
|
||||
# return None and log.
|
||||
"kwargs": {"content": "legacy", "type": "not-a-real-message-type"},
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus"):
|
||||
result = serde._revive_lc2(load)
|
||||
|
||||
assert result is None, (
|
||||
"Legacy method=[None, 'construct'] payloads with kwargs the default "
|
||||
"ctor rejects must now return None (no construct() fallback)."
|
||||
)
|
||||
|
||||
matching = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.name == "langgraph.checkpoint.serde.jsonplus"
|
||||
and r.levelno == logging.WARNING
|
||||
and "langchain_core.messages.ai.AIMessage" in r.getMessage()
|
||||
and "legacy_method_field=True" in r.getMessage()
|
||||
]
|
||||
assert matching, (
|
||||
"Expected a WARNING from langgraph.checkpoint.serde.jsonplus "
|
||||
"referencing the class id and legacy_method_field=True; "
|
||||
f"got records: {[(r.name, r.levelname, r.getMessage()) for r in caplog.records]}"
|
||||
)
|
||||
|
||||
|
||||
def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None:
|
||||
"""End-to-end: a `parse_raw` pickle gadget payload on a SAFE type must not run.
|
||||
|
||||
With method dispatch removed from `_revive_lc2`, the gadget bytes are never
|
||||
passed to `parse_raw` and therefore never reach `pickle.loads`.
|
||||
"""
|
||||
import os
|
||||
import pickle
|
||||
import tempfile
|
||||
|
||||
marker = tempfile.NamedTemporaryFile(
|
||||
prefix="lc2_block_proof_", suffix=".out", delete=False
|
||||
).name
|
||||
os.remove(marker) # ensure absent before the test runs
|
||||
|
||||
class _Gadget:
|
||||
def __reduce__(self) -> tuple:
|
||||
return (os.system, (f"touch {marker}",))
|
||||
|
||||
gadget_bytes = pickle.dumps(_Gadget(), protocol=0).decode("latin1")
|
||||
envelope = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"method": "parse_raw",
|
||||
"args": [gadget_bytes],
|
||||
"kwargs": {"content_type": "application/pickle", "allow_pickle": True},
|
||||
}
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
try:
|
||||
serde.loads_typed(("json", json.dumps(envelope).encode()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert not os.path.exists(marker), (
|
||||
"Pickle gadget executed via parse_raw on AIMessage lc=2 envelope"
|
||||
)
|
||||
|
||||
|
||||
def test_serde_jsonplus_bytearray() -> None:
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
|
||||
Generated
+7
-7
@@ -229,11 +229,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -300,7 +300,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -383,7 +383,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -396,9 +396,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3675,12 +3675,11 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.20"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
|
||||
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.6.3.tgz#a3d8ad58d66a47d3697e3c69b2be3f6df5233190"
|
||||
integrity sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
leven@^3.1.0:
|
||||
version "3.1.0"
|
||||
@@ -4848,7 +4847,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==
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.9.7",
|
||||
"turbo": "^2.9.14",
|
||||
"typescript": "^5.9.3",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
|
||||
@@ -166,35 +166,35 @@
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da"
|
||||
integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q==
|
||||
|
||||
"@turbo/darwin-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.7.tgz#46fddae01ea7817192dc6ff36a678cc3879b753b"
|
||||
integrity sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg==
|
||||
"@turbo/darwin-64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.14.tgz#b9ec6ac637b9c5fdba5dae9d743f5d121f091242"
|
||||
integrity sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==
|
||||
|
||||
"@turbo/darwin-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.7.tgz#cd7cf3a024509af0f59ae3885216d62fd090fdb6"
|
||||
integrity sha512-mA0FIPMwwN3lodDkQYaGxj6PeT7ZaN5aCEbkKn/WB+ZB9yJdVWA4J83GH7t43jqDc5dcnVluVN5UFx3plRiXhA==
|
||||
"@turbo/darwin-arm64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.14.tgz#99d19f3e59842c595d2828c72a2e4ef537408f38"
|
||||
integrity sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==
|
||||
|
||||
"@turbo/linux-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.7.tgz#25ee9a2cb3042498a3203e14653b14041105020f"
|
||||
integrity sha512-fEbUYpgb5l7P+q+5tsWF2gw+/GSjUsuUTcnfm+f0lozUjgcjLKyOat6PgtAChmIFcTPchCL/8rJ3TvkBy01gfA==
|
||||
"@turbo/linux-64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.14.tgz#9c907434f091cd75529f5496516f79b71935d2bb"
|
||||
integrity sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==
|
||||
|
||||
"@turbo/linux-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.7.tgz#5b19d6249a50cb8153b8f2bc22ad10bbda614fbe"
|
||||
integrity sha512-VkUjulo9ytfHKUHOS5gy0XPoh4CTKPXWCL8nLdrlHVi9fSut31ECeUqnm/dAbETP5D4xo9mH9XkJ+qMzGe/zmg==
|
||||
"@turbo/linux-arm64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.14.tgz#79ae9060e6ee9e0fb784ae0747e980f582e75313"
|
||||
integrity sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==
|
||||
|
||||
"@turbo/windows-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.7.tgz#900645776fa44ff8333e801d890992736f6dbc72"
|
||||
integrity sha512-/GWdY6/x4aIHqkYJq596Rpdk1x0MkpRPkJcLAoB3yGRwyUms0+u2F1GnV54IbyAZTeKLRWSJKzNC+QwVGdYchA==
|
||||
"@turbo/windows-64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.14.tgz#68a80f299f35189314184c88301caad982d1c0c2"
|
||||
integrity sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==
|
||||
|
||||
"@turbo/windows-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.7.tgz#e678d73fdbcbb12a679403fe7ba070fb67f37810"
|
||||
integrity sha512-xBBgxCC5PK2+WZ1PPRZdp+aJ0bMBcEbweXWux3RUHJvX9ZodcoQySkrW6qt+ahb+uk8ZjyQodLfDwtVSoYds1w==
|
||||
"@turbo/windows-arm64@2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.14.tgz#4dc16684f0ddce53fafe56ad8f55205c4473d17a"
|
||||
integrity sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==
|
||||
|
||||
"@types/esrecurse@^4.3.1":
|
||||
version "4.3.1"
|
||||
@@ -1327,12 +1327,11 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.20"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
|
||||
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.7.1.tgz#d721ad2e5211f9a5373e8a84eb4fed36b5621971"
|
||||
integrity sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
@@ -1814,17 +1813,17 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
turbo@^2.9.7:
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.7.tgz#da6d5a821f0bde5174e1a55af6690dad5c2fb004"
|
||||
integrity sha512-epxzqVO2s0IxcSWcgb+qKrtco8isfe7g3VtiS6hkYnEK4A9XQDZbrtavQ6MtWR1KoQn+1fUomaQth2rfRHlUlg==
|
||||
turbo@^2.9.14:
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.14.tgz#d412fcc4c9bd8dba29cec5bbd54d5a74ab2b1bee"
|
||||
integrity sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==
|
||||
optionalDependencies:
|
||||
"@turbo/darwin-64" "2.9.7"
|
||||
"@turbo/darwin-arm64" "2.9.7"
|
||||
"@turbo/linux-64" "2.9.7"
|
||||
"@turbo/linux-arm64" "2.9.7"
|
||||
"@turbo/windows-64" "2.9.7"
|
||||
"@turbo/windows-arm64" "2.9.7"
|
||||
"@turbo/darwin-64" "2.9.14"
|
||||
"@turbo/darwin-arm64" "2.9.14"
|
||||
"@turbo/linux-64" "2.9.14"
|
||||
"@turbo/linux-arm64" "2.9.14"
|
||||
"@turbo/windows-64" "2.9.14"
|
||||
"@turbo/windows-arm64" "2.9.14"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
@@ -1900,7 +1899,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
+7
-7
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-core", specifier = ">=0.2.14" },
|
||||
{ name = "langgraph", specifier = ">=0.6.0,<2" },
|
||||
{ name = "shared", editable = "libs/shared" },
|
||||
]
|
||||
@@ -303,7 +303,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -316,9 +316,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -677,11 +677,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+6
-6
@@ -279,7 +279,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -292,9 +292,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -663,11 +663,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+6
-6
@@ -797,11 +797,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1174,7 +1174,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.32"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "python_full_version >= '3.11'" },
|
||||
@@ -1187,9 +1187,9 @@ dependencies = [
|
||||
{ name = "xxhash", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "zstandard", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518, upload-time = "2026-04-15T23:42:41.885Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272, upload-time = "2026-04-15T23:42:39.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import (
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.messages import BaseMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -303,6 +303,35 @@ class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler
|
||||
super().__init__(stream, subgraphs, parent_ns=parent_ns)
|
||||
self._streamed_run_ids: set[UUID] = set()
|
||||
|
||||
def _find_and_emit_messages(self, meta: Meta, response: Any) -> None:
|
||||
"""Like the v1 handler, but skip ToolMessage from node outputs.
|
||||
|
||||
Tool results belong on the tools channel / state in v3; v2-flagged streams
|
||||
must not replay finalized ToolMessages as chat tokens (see MessagesTransformer).
|
||||
Legacy v1-only `stream_mode="messages"` still emits ToolMessages (see subgraph
|
||||
streaming tests).
|
||||
"""
|
||||
if isinstance(response, BaseMessage) and not isinstance(response, ToolMessage):
|
||||
self._emit(meta, response, dedupe=True)
|
||||
elif isinstance(response, Sequence):
|
||||
for value in response:
|
||||
if isinstance(value, BaseMessage) and not isinstance(
|
||||
value, ToolMessage
|
||||
):
|
||||
self._emit(meta, value, dedupe=True)
|
||||
else:
|
||||
for value in _state_values(response):
|
||||
if isinstance(value, BaseMessage) and not isinstance(
|
||||
value, ToolMessage
|
||||
):
|
||||
self._emit(meta, value, dedupe=True)
|
||||
elif isinstance(value, Sequence):
|
||||
for item in value:
|
||||
if isinstance(item, BaseMessage) and not isinstance(
|
||||
item, ToolMessage
|
||||
):
|
||||
self._emit(meta, item, dedupe=True)
|
||||
|
||||
def on_llm_end(
|
||||
self,
|
||||
response: LLMResult,
|
||||
|
||||
@@ -62,6 +62,14 @@ class StreamMux:
|
||||
`extensions`, `_native` keys are recorded in `native_keys`, and
|
||||
any StreamChannel instances are bound and (if named) wired.
|
||||
|
||||
Transformers with `StreamTransformer.before_builtins = True` are
|
||||
registered ahead of the rest, preserving relative order within
|
||||
each lane. This lets content-mutating transformers (PII
|
||||
redaction, content filters, etc.) run before built-ins like
|
||||
`MessagesTransformer` that eagerly snapshot text fields into
|
||||
their projections. See `StreamTransformer.before_builtins` for
|
||||
the contract and foot-guns.
|
||||
|
||||
Args:
|
||||
transformers: Already-built transformer instances. Registered
|
||||
only on this mux — they are NOT cloned into child
|
||||
@@ -112,14 +120,31 @@ class StreamMux:
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
# Factories run first (they propagate to child mini-muxes
|
||||
# via `_make_child`), then any pre-built `transformers=`
|
||||
# instances are registered as root-only — they aren't cloned
|
||||
# for child scopes.
|
||||
# Factories run first (they propagate to child mini-muxes via
|
||||
# `_make_child`), then any pre-built `transformers=` instances
|
||||
# are registered as root-only — they aren't cloned for child
|
||||
# scopes. Within each group, transformers with
|
||||
# `before_builtins = True` are registered ahead of the rest so
|
||||
# they observe (and may mutate) events before built-ins like
|
||||
# `MessagesTransformer`. The order *within* each lane matches
|
||||
# the supplied sequence.
|
||||
pre: list[StreamTransformer] = []
|
||||
rest: list[StreamTransformer] = []
|
||||
if factories is not None:
|
||||
for factory in factories:
|
||||
self._register(factory(scope))
|
||||
transformer = factory(scope)
|
||||
(
|
||||
pre if getattr(transformer, "before_builtins", False) else rest
|
||||
).append(transformer)
|
||||
for transformer in (*pre, *rest):
|
||||
self._register(transformer)
|
||||
pre.clear()
|
||||
rest.clear()
|
||||
for transformer in transformers or ():
|
||||
(pre if getattr(transformer, "before_builtins", False) else rest).append(
|
||||
transformer
|
||||
)
|
||||
for transformer in (*pre, *rest):
|
||||
self._register(transformer)
|
||||
|
||||
def transformer_by_key(self, key: str) -> StreamTransformer | None:
|
||||
|
||||
@@ -91,11 +91,28 @@ class StreamTransformer(ABC):
|
||||
which modes a `stream_events(version="v3")` run requests from the graph.
|
||||
Empty tuple means the transformer consumes only synthetic
|
||||
events (or is purely passive).
|
||||
before_builtins: Opt-in for transformers that must run *before*
|
||||
built-in transformers like `MessagesTransformer` and
|
||||
`ToolCallTransformer`. The mux partitions factories by this
|
||||
flag at registration time: `before_builtins = True`
|
||||
transformers are registered first, then everything else in
|
||||
the order supplied. Within each lane, registration order is
|
||||
preserved. This is the supported hook for content-mutating
|
||||
transformers (PII redaction, profanity filters, etc.) whose
|
||||
mutations must land before built-ins eagerly snapshot text
|
||||
fields into their projections. **Foot-gun:** transformers
|
||||
in this lane see `tasks` events before `LifecycleTransformer`
|
||||
and `SubgraphTransformer` consume them — mutating
|
||||
`event["params"]["namespace"]` or the data dict's
|
||||
`id` / `result` / `error` / `interrupts` fields will desync
|
||||
their bookkeeping. Observe freely; mutate only fields no
|
||||
built-in reads (e.g. `delta.text` on `messages` events).
|
||||
"""
|
||||
|
||||
requires_async: ClassVar[bool] = False
|
||||
supports_sync: ClassVar[bool] = False
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ()
|
||||
before_builtins: ClassVar[bool] = False
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
"""Initialize the transformer with its mux's scope.
|
||||
|
||||
@@ -8,7 +8,7 @@ from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage
|
||||
from langchain_protocol.protocol import MessagesData
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
@@ -203,6 +203,7 @@ class MessagesTransformer(StreamTransformer):
|
||||
# Correlate protocol events back to a ChatModelStream by run_id
|
||||
# (attached to the event's metadata by StreamMessagesHandler).
|
||||
self._by_run: dict[str, ChatModelStream] = {}
|
||||
self._ignored_runs: set[str] = set()
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
# Cached as a list once for cheap equality with the protocol
|
||||
@@ -276,8 +277,10 @@ class MessagesTransformer(StreamTransformer):
|
||||
self._route_protocol_event(
|
||||
cast("MessagesData", payload), run_id=run_id, node=node
|
||||
)
|
||||
elif isinstance(payload, BaseMessage) and not isinstance(
|
||||
payload, AIMessageChunk
|
||||
elif (
|
||||
isinstance(payload, BaseMessage)
|
||||
and not isinstance(payload, AIMessageChunk)
|
||||
and not isinstance(payload, ToolMessage)
|
||||
):
|
||||
self._route_whole_message(payload, node=node)
|
||||
# Legacy AIMessageChunk tuples (from on_llm_new_token) are ignored;
|
||||
@@ -295,6 +298,11 @@ class MessagesTransformer(StreamTransformer):
|
||||
) -> None:
|
||||
event_type = event.get("event")
|
||||
if event_type == "message-start":
|
||||
# Tool results are exposed on the tools projection and state
|
||||
# snapshots; run.messages is the chat-token projection.
|
||||
if event.get("role") == "tool":
|
||||
self._ignored_runs.add(run_id)
|
||||
return
|
||||
message_id = event.get("message_id")
|
||||
stream = self._make_stream(
|
||||
namespace=[],
|
||||
@@ -304,6 +312,9 @@ class MessagesTransformer(StreamTransformer):
|
||||
self._by_run[run_id] = stream
|
||||
self._log.push(stream)
|
||||
stream.dispatch(event)
|
||||
elif run_id in self._ignored_runs:
|
||||
if event_type == "message-finish":
|
||||
self._ignored_runs.discard(run_id)
|
||||
elif run_id in self._by_run:
|
||||
stream = self._by_run[run_id]
|
||||
stream.dispatch(event)
|
||||
@@ -319,12 +330,14 @@ class MessagesTransformer(StreamTransformer):
|
||||
def finalize(self) -> None:
|
||||
"""Clear any routing state — streams close themselves via `message-finish`."""
|
||||
self._by_run.clear()
|
||||
self._ignored_runs.clear()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Propagate run error to any streams still open when the graph fails."""
|
||||
for stream in list(self._by_run.values()):
|
||||
stream.fail(err)
|
||||
self._by_run.clear()
|
||||
self._ignored_runs.clear()
|
||||
|
||||
|
||||
SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Tests for `StreamTransformer.before_builtins` lane ordering.
|
||||
|
||||
`before_builtins = True` transformers are registered ahead of the
|
||||
rest, preserving relative order within each lane. This lets
|
||||
content-mutating transformers run before built-ins like
|
||||
`MessagesTransformer` that eagerly snapshot text fields into their
|
||||
projections.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import StreamTransformer
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import (
|
||||
LifecycleTransformer,
|
||||
MessagesTransformer,
|
||||
TasksTransformer,
|
||||
)
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _messages_event(namespace: list[str], data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {"namespace": namespace, "timestamp": TS, "data": data},
|
||||
}
|
||||
|
||||
|
||||
class _Tap(StreamTransformer):
|
||||
"""Records the order it observed each event."""
|
||||
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ()
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = (), *, label: str = "tap") -> None:
|
||||
super().__init__(scope)
|
||||
self.label = label
|
||||
self.log: list[str] = []
|
||||
self._channel: StreamChannel[str] = StreamChannel()
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {f"tap_{self.label}": self._channel}
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
self.log.append(self.label)
|
||||
return True
|
||||
|
||||
|
||||
class _PreTap(_Tap):
|
||||
before_builtins: ClassVar[bool] = True
|
||||
|
||||
|
||||
class _TextRedactor(StreamTransformer):
|
||||
"""Mutates `text-delta` events in place to a fixed redacted string."""
|
||||
|
||||
before_builtins: ClassVar[bool] = True
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ("messages",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._channel: StreamChannel[str] = StreamChannel()
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"redactor": self._channel}
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
if event.get("method") != "messages":
|
||||
return True
|
||||
payload, _meta = event["params"]["data"]
|
||||
if isinstance(payload, dict) and payload.get("event") == "content-block-delta":
|
||||
delta = payload.get("delta") or {}
|
||||
if delta.get("type") == "text-delta":
|
||||
delta["text"] = "[REDACTED]"
|
||||
return True
|
||||
|
||||
|
||||
def test_before_builtins_factories_run_before_others() -> None:
|
||||
"""A `before_builtins=True` factory is registered ahead of the rest."""
|
||||
|
||||
seen: list[type[StreamTransformer]] = []
|
||||
|
||||
class _PostTap(_Tap):
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope, label="post")
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
seen.append(_PostTap)
|
||||
return True
|
||||
|
||||
class _EagerTap(_Tap):
|
||||
before_builtins: ClassVar[bool] = True
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope, label="eager")
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
seen.append(_EagerTap)
|
||||
return True
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[_PostTap, _EagerTap],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
# `_EagerTap` was supplied second but should be registered first.
|
||||
types_in_order = [type(t) for t in mux._transformers]
|
||||
assert types_in_order.index(_EagerTap) < types_in_order.index(_PostTap)
|
||||
|
||||
mux.push(
|
||||
_messages_event([], ({"event": "message-start", "role": "ai", "id": "m1"}, {}))
|
||||
)
|
||||
# And it ran first when the event was dispatched.
|
||||
assert seen == [_EagerTap, _PostTap]
|
||||
|
||||
|
||||
def test_within_lane_order_preserved() -> None:
|
||||
"""Within each lane, the supplied order is the registration order."""
|
||||
|
||||
class _A(_PreTap):
|
||||
pass
|
||||
|
||||
class _B(_PreTap):
|
||||
pass
|
||||
|
||||
class _C(_Tap):
|
||||
pass
|
||||
|
||||
class _D(_Tap):
|
||||
pass
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
lambda scope: _C(scope, label="c"),
|
||||
lambda scope: _A(scope, label="a"),
|
||||
lambda scope: _D(scope, label="d"),
|
||||
lambda scope: _B(scope, label="b"),
|
||||
],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
order = [t.label for t in mux._transformers] # type: ignore[attr-defined]
|
||||
# Pre lane (a, b) ahead of default lane (c, d). Within each, supplied order kept.
|
||||
assert order == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
def test_redactor_runs_before_messages_transformer() -> None:
|
||||
"""Content mutated by a pre-lane transformer reaches `MessagesTransformer`."""
|
||||
|
||||
# Order supplied: built-ins first (as in pregel/main.py), then the
|
||||
# opt-in pre-lane redactor. Partitioning should still register the
|
||||
# redactor first.
|
||||
mux = StreamMux(
|
||||
factories=[MessagesTransformer, _TextRedactor],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
types_in_order = [type(t) for t in mux._transformers]
|
||||
assert types_in_order.index(_TextRedactor) < types_in_order.index(
|
||||
MessagesTransformer
|
||||
)
|
||||
|
||||
|
||||
def test_lifecycle_unaffected_by_pre_lane_observer() -> None:
|
||||
"""An observer-only pre-lane transformer doesn't break lifecycle bookkeeping."""
|
||||
|
||||
class _NoopPreObserver(StreamTransformer):
|
||||
before_builtins: ClassVar[bool] = True
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ("tasks",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._channel: StreamChannel[str] = StreamChannel()
|
||||
self.seen: list[tuple[str, ...]] = []
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"noop_observer": self._channel}
|
||||
|
||||
def process(self, event: dict[str, Any]) -> bool:
|
||||
if event.get("method") == "tasks":
|
||||
self.seen.append(tuple(event["params"]["namespace"]))
|
||||
return True
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[LifecycleTransformer, TasksTransformer, _NoopPreObserver],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
types_in_order = [type(t) for t in mux._transformers]
|
||||
assert types_in_order.index(_NoopPreObserver) < types_in_order.index(
|
||||
LifecycleTransformer
|
||||
)
|
||||
|
||||
observer = next(t for t in mux._transformers if isinstance(t, _NoopPreObserver))
|
||||
lifecycle = next(
|
||||
t for t in mux._transformers if isinstance(t, LifecycleTransformer)
|
||||
)
|
||||
|
||||
# Push a synthetic `tasks` event that lifecycle would normally track.
|
||||
mux.push(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "tasks",
|
||||
"params": {
|
||||
"namespace": ["child:abc"],
|
||||
"timestamp": TS,
|
||||
"data": {"name": "child"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Pre-lane observer saw the event, AND lifecycle's bookkeeping still
|
||||
# registered the new namespace (the observer didn't mutate anything).
|
||||
assert observer.seen == [("child:abc",)]
|
||||
assert ("child:abc",) in lifecycle._seen # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_default_is_false() -> None:
|
||||
"""`StreamTransformer.before_builtins` defaults to False."""
|
||||
|
||||
assert StreamTransformer.before_builtins is False
|
||||
assert MessagesTransformer.before_builtins is False
|
||||
assert LifecycleTransformer.before_builtins is False
|
||||
|
||||
|
||||
def test_pre_lane_mutation_lands_in_messages_projection() -> None:
|
||||
"""End-to-end: text mutated by a pre-lane transformer is what
|
||||
`MessagesTransformer` snapshots into its `ChatModelStream` projection.
|
||||
|
||||
Without `before_builtins`, the redactor would run after
|
||||
MessagesTransformer's eager extraction and the projection would
|
||||
contain the raw, un-redacted text.
|
||||
"""
|
||||
|
||||
mux = StreamMux(
|
||||
factories=[MessagesTransformer, _TextRedactor],
|
||||
scope=(),
|
||||
is_async=False,
|
||||
)
|
||||
messages_transformer = next(
|
||||
t for t in mux._transformers if isinstance(t, MessagesTransformer)
|
||||
)
|
||||
# Unblock both the mux's main log and the messages projection log so
|
||||
# synthetic pushes are accepted without a real consumer attached.
|
||||
mux._events._subscribed = True
|
||||
messages_transformer._log._subscribed = True
|
||||
|
||||
meta = {"langgraph_node": "model", "run_id": "run-1"}
|
||||
|
||||
# message-start → MessagesTransformer creates a ChatModelStream.
|
||||
mux.push(
|
||||
_messages_event(
|
||||
[],
|
||||
({"event": "message-start", "role": "ai", "id": "msg-1"}, meta),
|
||||
)
|
||||
)
|
||||
|
||||
# content-block-delta carrying the secret. The redactor (pre-lane)
|
||||
# mutates `delta.text` BEFORE MessagesTransformer snapshots it.
|
||||
mux.push(
|
||||
_messages_event(
|
||||
[],
|
||||
(
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text-delta", "text": "secret@example.com"},
|
||||
},
|
||||
meta,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Capture the still-open stream before message-finish removes it from
|
||||
# MessagesTransformer's `_by_run` dict.
|
||||
chat_stream = messages_transformer._by_run["run-1"] # type: ignore[attr-defined]
|
||||
|
||||
# message-finish → closes the stream.
|
||||
mux.push(
|
||||
_messages_event(
|
||||
[],
|
||||
({"event": "message-finish"}, meta),
|
||||
)
|
||||
)
|
||||
|
||||
# The redactor mutated `delta.text` to "[REDACTED]" before
|
||||
# MessagesTransformer snapshotted the string into the text
|
||||
# accumulator. Without `before_builtins`, the accumulator would hold
|
||||
# the raw "secret@example.com".
|
||||
assert chat_stream._text_acc == "[REDACTED]", ( # type: ignore[attr-defined]
|
||||
f"expected redacted text in projection, got {chat_stream._text_acc!r}"
|
||||
)
|
||||
@@ -12,7 +12,7 @@ from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
@@ -213,6 +213,23 @@ class TestProtocolEventRouting:
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
|
||||
def test_tool_role_protocol_events_are_ignored(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in [
|
||||
{"event": "message-start", "role": "tool", "message_id": "tool-msg-1"},
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": "[]"},
|
||||
},
|
||||
{"event": "message-finish", "reason": "stop"},
|
||||
]:
|
||||
t.process(_proto_event(evt, run_id="tool-run"))
|
||||
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
assert t._ignored_runs == set()
|
||||
|
||||
def test_concurrent_streams_routed_by_run_id(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
life_a = _lifecycle(text="aaaa", message_id="run-a")
|
||||
@@ -273,6 +290,29 @@ class TestWholeMessageFallback:
|
||||
assert stream.done
|
||||
assert stream.output.text == "the full answer"
|
||||
|
||||
def test_whole_tool_message_is_ignored(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (
|
||||
ToolMessage(
|
||||
content="[]",
|
||||
id="tool-msg-1",
|
||||
tool_call_id="call_1",
|
||||
),
|
||||
{"langgraph_node": "tools"},
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
|
||||
def test_whole_message_has_full_lifecycle(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_whole_msg("full"))
|
||||
@@ -849,6 +889,23 @@ class TestStreamMessagesHandlerV2Unit:
|
||||
|
||||
assert emitted == []
|
||||
|
||||
def test_on_chain_end_does_not_emit_tool_messages(self) -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.pregel._messages import StreamMessagesHandlerV2
|
||||
|
||||
emitted: list[Any] = []
|
||||
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
|
||||
run_id = uuid4()
|
||||
handler.metadata[run_id] = ((), {"langgraph_node": "tools"})
|
||||
|
||||
handler.on_chain_end(
|
||||
{"messages": [ToolMessage(content="[]", tool_call_id="call_1")]},
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
assert emitted == []
|
||||
|
||||
def test_on_llm_end_dedupes_when_final_message_id_differs(self) -> None:
|
||||
"""A streamed v2 message should not be emitted again from the final
|
||||
AIMessage fallback when its final id does not match `message-start`."""
|
||||
|
||||
Generated
+8
-8
@@ -871,11 +871,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1382,7 +1382,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1563,7 +1563,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1867,7 +1867,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1880,9 +1880,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
@@ -5,12 +5,42 @@ from __future__ import annotations
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
from langgraph.prebuilt._tool_call_stream import ToolCallStream
|
||||
|
||||
|
||||
def _is_serialized_tool_message(value: Any) -> bool:
|
||||
"""Detect a serialized LangChain `ToolMessage` payload.
|
||||
|
||||
Example:
|
||||
{
|
||||
"lc": 1,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ToolMessage"],
|
||||
"kwargs": {"content": "raw tool result", "tool_call_id": "call_1"},
|
||||
}
|
||||
"""
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and value.get("type") == "constructor"
|
||||
and isinstance(value.get("id"), list)
|
||||
and value["id"][-1] == "ToolMessage"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_tool_output(output: Any) -> Any:
|
||||
if isinstance(output, ToolMessage):
|
||||
return output.content
|
||||
if _is_serialized_tool_message(output):
|
||||
kwargs = output.get("kwargs")
|
||||
if isinstance(kwargs, dict):
|
||||
return kwargs.get("content")
|
||||
return output
|
||||
|
||||
|
||||
class ToolCallTransformer(StreamTransformer):
|
||||
"""Project `tools` channel events into `ToolCallStream` handles.
|
||||
|
||||
@@ -109,7 +139,7 @@ class ToolCallTransformer(StreamTransformer):
|
||||
elif event_type == "tool-finished":
|
||||
stream = self._active.pop(tool_call_id, None)
|
||||
if stream is not None:
|
||||
stream._finish(data.get("output"))
|
||||
stream._finish(_normalize_tool_output(data.get("output")))
|
||||
elif event_type == "tool-error":
|
||||
stream = self._active.pop(tool_call_id, None)
|
||||
if stream is not None:
|
||||
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
@@ -128,6 +128,42 @@ class TestToolCallTransformerUnit:
|
||||
assert stream.error is None
|
||||
assert "tc1" not in transformer._active
|
||||
|
||||
def test_finish_unwraps_tool_message_output(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
|
||||
stream = transformer._active["tc1"]
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-finished",
|
||||
"tc1",
|
||||
output=ToolMessage(content="done", tool_call_id="tc1"),
|
||||
)
|
||||
)
|
||||
assert stream.completed is True
|
||||
assert stream.output == "done"
|
||||
|
||||
def test_finish_unwraps_serialized_tool_message_output(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
|
||||
stream = transformer._active["tc1"]
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-finished",
|
||||
"tc1",
|
||||
output={
|
||||
"lc": 1,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ToolMessage"],
|
||||
"kwargs": {
|
||||
"content": "serialized done",
|
||||
"tool_call_id": "tc1",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert stream.completed is True
|
||||
assert stream.output == "serialized done"
|
||||
|
||||
def test_error_closes_stream(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="boom"))
|
||||
|
||||
Generated
+8
-8
@@ -214,11 +214,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -285,7 +285,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -369,7 +369,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -636,7 +636,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -649,9 +649,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import EncryptionContext
|
||||
|
||||
__version__ = "0.3.14"
|
||||
__version__ = "0.3.15"
|
||||
|
||||
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Literal, cast, overload
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.schema import (
|
||||
Assistant,
|
||||
AssistantSelectField,
|
||||
@@ -84,7 +85,9 @@ class AssistantsClient:
|
||||
```
|
||||
"""
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def get_graph(
|
||||
@@ -142,7 +145,9 @@ class AssistantsClient:
|
||||
query_params.update(params)
|
||||
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/graph",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def get_schemas(
|
||||
@@ -263,7 +268,9 @@ class AssistantsClient:
|
||||
|
||||
"""
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def get_subgraphs(
|
||||
@@ -293,13 +300,13 @@ class AssistantsClient:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -436,7 +443,7 @@ class AssistantsClient:
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return await self.http.patch(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -479,7 +486,7 @@ class AssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
await self.http.delete(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -686,7 +693,7 @@ class AssistantsClient:
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return await self.http.post(
|
||||
f"/assistants/{assistant_id}/versions",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/versions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -726,7 +733,7 @@ class AssistantsClient:
|
||||
payload: dict[str, Any] = {"version": version}
|
||||
|
||||
return await self.http.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/latest",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime, tzinfo
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _resolve_timezone
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
|
||||
from langgraph_sdk.schema import (
|
||||
All,
|
||||
Config,
|
||||
@@ -166,7 +166,7 @@ class CronClient:
|
||||
payload["multitask_strategy"] = multitask_strategy
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/runs/crons",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -315,7 +315,9 @@ class CronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
|
||||
await self.http.delete(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
@@ -402,7 +404,7 @@ class CronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return await self.http.patch(
|
||||
f"/runs/crons/{cron_id}",
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -12,6 +12,7 @@ import httpx
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_get_run_metadata_from_response,
|
||||
_quote_path_param,
|
||||
_sse_to_v2_dict,
|
||||
)
|
||||
from langgraph_sdk.schema import (
|
||||
@@ -337,7 +338,7 @@ class RunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/stream"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
|
||||
if thread_id is not None
|
||||
else "/runs/stream"
|
||||
)
|
||||
@@ -596,7 +597,7 @@ class RunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/runs" if thread_id else "/runs",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
@@ -821,7 +822,9 @@ class RunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
|
||||
if thread_id is not None
|
||||
else "/runs/wait"
|
||||
)
|
||||
|
||||
def on_response(res: httpx.Response):
|
||||
@@ -895,7 +898,9 @@ class RunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/runs", params=query_params, headers=headers
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def get(
|
||||
@@ -930,7 +935,9 @@ class RunsClient:
|
||||
"""
|
||||
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def cancel(
|
||||
@@ -978,14 +985,14 @@ class RunsClient:
|
||||
query_params.update(params)
|
||||
if wait:
|
||||
return await self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
"POST",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
@@ -1081,7 +1088,7 @@ class RunsClient:
|
||||
|
||||
"""
|
||||
return await self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/join",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
|
||||
"GET",
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -1136,7 +1143,7 @@ class RunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/runs/{run_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
|
||||
"GET",
|
||||
params=query_params,
|
||||
headers={
|
||||
@@ -1177,5 +1184,7 @@ class RunsClient:
|
||||
|
||||
"""
|
||||
await self.http.delete(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.schema import (
|
||||
Checkpoint,
|
||||
Json,
|
||||
@@ -90,7 +91,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -254,7 +255,7 @@ class ThreadsClient:
|
||||
if return_minimal:
|
||||
request_headers["Prefer"] = "return=minimal"
|
||||
return await self.http.patch(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
json=payload,
|
||||
headers=request_headers or None,
|
||||
params=params,
|
||||
@@ -287,7 +288,9 @@ class ThreadsClient:
|
||||
```
|
||||
|
||||
"""
|
||||
await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
|
||||
await self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -430,7 +433,10 @@ class ThreadsClient:
|
||||
|
||||
"""
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/copy",
|
||||
json=None,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def prune(
|
||||
@@ -586,7 +592,7 @@ class ThreadsClient:
|
||||
"""
|
||||
if checkpoint:
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/state/checkpoint",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -596,7 +602,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -605,7 +611,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/state",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -670,7 +676,10 @@ class ThreadsClient:
|
||||
if as_node:
|
||||
payload["as_node"] = as_node
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def get_history(
|
||||
@@ -719,7 +728,7 @@ class ThreadsClient:
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/history",
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -763,7 +772,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/stream",
|
||||
"GET",
|
||||
headers={
|
||||
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
|
||||
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -198,6 +198,39 @@ def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in d.items() if v is not None}
|
||||
|
||||
|
||||
def _quote_path_param(value: Any) -> str:
|
||||
"""Encode a value for safe interpolation into a request path segment.
|
||||
|
||||
Path segments are encoded with ``safe=""`` so that ``/`` and other reserved
|
||||
characters are escaped. Standalone dot-segments (``.`` and ``..``) are also
|
||||
encoded because some URL-handling stacks (including ``httpx``) collapse
|
||||
them client-side as relative-path traversal before transmission. The value
|
||||
is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types
|
||||
directly without changing call sites.
|
||||
|
||||
A properly formed identifier (for example, a standard UUID, which contains
|
||||
no dots or reserved characters) round-trips through this function
|
||||
unchanged.
|
||||
|
||||
Raises:
|
||||
TypeError: If `value` is `None` or a `bytes`/`bytearray` instance.
|
||||
Coercing those would produce misleading paths (e.g. `/threads/None`),
|
||||
so surface the caller bug instead.
|
||||
"""
|
||||
if value is None:
|
||||
raise TypeError("path parameter must not be None")
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID")
|
||||
quoted = quote(str(value), safe="")
|
||||
# Bare "." or ".." (or any all-dot string) acts as a relative-path segment
|
||||
# that some HTTP stacks (including ``httpx``) collapse client-side before
|
||||
# transmission. Encode the dots so the segment becomes opaque to that
|
||||
# logic. Mixed values like "agent.v1" are unaffected.
|
||||
if quoted and all(c == "." for c in quoted):
|
||||
quoted = "%2E" * len(quoted)
|
||||
return quoted
|
||||
|
||||
|
||||
_registered_transports: list[httpx.ASGITransport] = []
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Literal, cast, overload
|
||||
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
Assistant,
|
||||
@@ -83,7 +84,9 @@ class SyncAssistantsClient:
|
||||
|
||||
"""
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def get_graph(
|
||||
@@ -136,7 +139,9 @@ class SyncAssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/graph",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def get_schemas(
|
||||
@@ -269,7 +274,9 @@ class SyncAssistantsClient:
|
||||
|
||||
"""
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def get_subgraphs(
|
||||
@@ -297,13 +304,13 @@ class SyncAssistantsClient:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -438,7 +445,7 @@ class SyncAssistantsClient:
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return self.http.patch(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -481,7 +488,7 @@ class SyncAssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
self.http.delete(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -685,7 +692,7 @@ class SyncAssistantsClient:
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return self.http.post(
|
||||
f"/assistants/{assistant_id}/versions",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/versions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -724,7 +731,7 @@ class SyncAssistantsClient:
|
||||
payload: dict[str, Any] = {"version": version}
|
||||
|
||||
return self.http.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/latest",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, tzinfo
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk._shared.utilities import _resolve_timezone
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
All,
|
||||
@@ -156,7 +156,7 @@ class SyncCronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/runs/crons",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -304,7 +304,9 @@ class SyncCronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
|
||||
self.http.delete(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -391,7 +393,7 @@ class SyncCronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.patch(
|
||||
f"/runs/crons/{cron_id}",
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -11,6 +11,7 @@ import httpx
|
||||
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_get_run_metadata_from_response,
|
||||
_quote_path_param,
|
||||
_sse_to_v2_dict,
|
||||
)
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
@@ -332,7 +333,7 @@ class SyncRunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/stream"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
|
||||
if thread_id is not None
|
||||
else "/runs/stream"
|
||||
)
|
||||
@@ -591,7 +592,7 @@ class SyncRunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/runs" if thread_id else "/runs",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
@@ -825,7 +826,9 @@ class SyncRunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
|
||||
if thread_id is not None
|
||||
else "/runs/wait"
|
||||
)
|
||||
return self.http.request_reconnect(
|
||||
endpoint,
|
||||
@@ -879,7 +882,9 @@ class SyncRunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/runs", params=query_params, headers=headers
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def get(
|
||||
@@ -912,7 +917,9 @@ class SyncRunsClient:
|
||||
"""
|
||||
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def cancel(
|
||||
@@ -960,14 +967,14 @@ class SyncRunsClient:
|
||||
query_params.update(params)
|
||||
if wait:
|
||||
return self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
"POST",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
@@ -1063,7 +1070,7 @@ class SyncRunsClient:
|
||||
|
||||
"""
|
||||
return self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/join",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
|
||||
"GET",
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -1117,7 +1124,7 @@ class SyncRunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/runs/{run_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
|
||||
"GET",
|
||||
params=query_params,
|
||||
headers={
|
||||
@@ -1158,5 +1165,7 @@ class SyncRunsClient:
|
||||
|
||||
"""
|
||||
self.http.delete(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
Checkpoint,
|
||||
@@ -88,7 +89,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -250,7 +251,7 @@ class SyncThreadsClient:
|
||||
if return_minimal:
|
||||
request_headers["Prefer"] = "return=minimal"
|
||||
return self.http.patch(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
json=payload,
|
||||
headers=request_headers or None,
|
||||
params=params,
|
||||
@@ -282,7 +283,9 @@ class SyncThreadsClient:
|
||||
```
|
||||
|
||||
"""
|
||||
self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
|
||||
self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
@@ -421,7 +424,10 @@ class SyncThreadsClient:
|
||||
|
||||
"""
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/copy",
|
||||
json=None,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def prune(
|
||||
@@ -576,7 +582,7 @@ class SyncThreadsClient:
|
||||
"""
|
||||
if checkpoint:
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/state/checkpoint",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -586,7 +592,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -595,7 +601,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/state",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -657,7 +663,10 @@ class SyncThreadsClient:
|
||||
if as_node:
|
||||
payload["as_node"] = as_node
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def get_history(
|
||||
@@ -707,7 +716,7 @@ class SyncThreadsClient:
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/history",
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -752,7 +761,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/stream",
|
||||
"GET",
|
||||
headers={
|
||||
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Regression tests for path-segment encoding of caller-supplied identifiers.
|
||||
|
||||
Covers GHSA-w39p-vh2g-g8g5: identifier values interpolated into request paths
|
||||
are encoded so the resulting request addresses the resource the SDK method
|
||||
indicates, even if the identifier contains characters with special meaning in
|
||||
URL paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.client import (
|
||||
AssistantsClient,
|
||||
CronClient,
|
||||
HttpClient,
|
||||
RunsClient,
|
||||
SyncAssistantsClient,
|
||||
SyncCronClient,
|
||||
SyncHttpClient,
|
||||
SyncRunsClient,
|
||||
SyncThreadsClient,
|
||||
ThreadsClient,
|
||||
)
|
||||
|
||||
|
||||
class TestQuotePathParam:
|
||||
"""Unit tests for the encoding helper itself."""
|
||||
|
||||
def test_uuid_round_trips_unchanged(self) -> None:
|
||||
uuid_value = "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert _quote_path_param(uuid_value) == uuid_value
|
||||
|
||||
def test_simple_opaque_id_round_trips_unchanged(self) -> None:
|
||||
assert _quote_path_param("thread_123") == "thread_123"
|
||||
assert _quote_path_param("asst_abc") == "asst_abc"
|
||||
|
||||
def test_slash_is_encoded(self) -> None:
|
||||
assert _quote_path_param("foo/bar") == "foo%2Fbar"
|
||||
|
||||
def test_bare_dot_segments_are_encoded(self) -> None:
|
||||
# All-dot strings are encoded to make them opaque to HTTP stacks that
|
||||
# collapse "./.." path segments client-side.
|
||||
assert _quote_path_param(".") == "%2E"
|
||||
assert _quote_path_param("..") == "%2E%2E"
|
||||
assert _quote_path_param("...") == "%2E%2E%2E"
|
||||
# Mixed values that happen to contain dots are not affected.
|
||||
assert _quote_path_param("agent.v1") == "agent.v1"
|
||||
# Subsequent ``/`` characters are encoded regardless.
|
||||
assert _quote_path_param("../bar") == "..%2Fbar"
|
||||
|
||||
def test_full_pivot_payload_is_encoded(self) -> None:
|
||||
# A caller-supplied identifier that, if interpolated raw, would route
|
||||
# the request to a different resource type.
|
||||
payload = "../assistants/abc-123"
|
||||
encoded = _quote_path_param(payload)
|
||||
assert encoded == "..%2Fassistants%2Fabc-123"
|
||||
assert "/" not in encoded
|
||||
|
||||
def test_non_string_values_are_coerced_to_str(self) -> None:
|
||||
import uuid
|
||||
|
||||
uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
assert _quote_path_param(uid) == str(uid)
|
||||
assert _quote_path_param(42) == "42"
|
||||
|
||||
def test_none_value_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError, match="must not be None"):
|
||||
_quote_path_param(None)
|
||||
|
||||
def test_bytes_value_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError, match="must not be bytes"):
|
||||
_quote_path_param(b"bytes")
|
||||
with pytest.raises(TypeError, match="must not be bytes"):
|
||||
_quote_path_param(bytearray(b"bytes"))
|
||||
|
||||
|
||||
def _wire_path(request: httpx.Request) -> str:
|
||||
"""Return the path as it goes on the wire (preserves percent-encoding)."""
|
||||
return request.url.raw_path.decode("ascii")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAsyncPathEncoding:
|
||||
"""Async-client tests that verify the encoded path actually lands on the wire.
|
||||
|
||||
Note: ``request.url.path`` is the percent-decoded display form. The bytes
|
||||
that actually go on the wire are in ``request.url.raw_path``; that is what
|
||||
the server's router sees and what these tests inspect.
|
||||
"""
|
||||
|
||||
async def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# The identifier is encoded so the wire path stays inside `/threads/...`.
|
||||
# The encoded segment must not contain literal slashes that could let
|
||||
# the server re-route to a different resource type.
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
assert "%2F" in segment
|
||||
assert segment == "..%2Fassistants%2Fabc-123"
|
||||
|
||||
async def test_threads_update_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.update("../assistants/abc-123", metadata={"x": 1})
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "PATCH"
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_threads_delete_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.delete("../runs/crons/some-cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_assistants_get_with_pivot_payload_stays_on_assistants(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"assistant_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = AssistantsClient(HttpClient(client))
|
||||
await assistants_client.get("../threads/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/assistants/")
|
||||
segment = wire[len("/assistants/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
runs_client = RunsClient(HttpClient(client))
|
||||
# Both identifier values supplied as path-traversal payloads.
|
||||
await runs_client.delete("..", "../runs/crons/cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
# The path should match `/threads/{quoted_thread}/runs/{quoted_run}`
|
||||
# exactly. Neither segment should contain literal slashes.
|
||||
assert wire.startswith("/threads/")
|
||||
assert "/runs/crons/" not in wire
|
||||
parts = wire.split("/")
|
||||
# Expected shape: ['', 'threads', '<encoded ..>', 'runs', '<encoded ..>']
|
||||
assert len(parts) == 5
|
||||
assert parts[1] == "threads"
|
||||
assert parts[3] == "runs"
|
||||
# Encoded thread_id and run_id are between literal slashes.
|
||||
assert parts[2] == "%2E%2E"
|
||||
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
|
||||
|
||||
async def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
crons_client = CronClient(HttpClient(client))
|
||||
await crons_client.delete("../../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/runs/crons/")
|
||||
segment = wire[len("/runs/crons/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_threads_get_state_with_pivot_checkpoint_id_stays_on_state(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get_state(thread_id="tid-1", checkpoint_id="../runs")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# Wire path must stay on `/threads/{tid}/state/...`, not pivot to
|
||||
# `/threads/tid-1/runs`.
|
||||
assert wire.startswith("/threads/tid-1/state/")
|
||||
# Strip query string before checking the checkpoint segment.
|
||||
path_only = wire.split("?", 1)[0]
|
||||
segment = path_only[len("/threads/tid-1/state/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Fruns"
|
||||
|
||||
async def test_assistants_get_subgraphs_with_pivot_namespace_stays_on_subgraphs(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = AssistantsClient(HttpClient(client))
|
||||
await assistants_client.get_subgraphs("aid-1", namespace="../foo")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# Wire path must stay on `/assistants/{aid}/subgraphs/...`.
|
||||
assert wire.startswith("/assistants/aid-1/subgraphs/")
|
||||
# Strip query string before checking the namespace segment.
|
||||
path_only = wire.split("?", 1)[0]
|
||||
segment = path_only[len("/assistants/aid-1/subgraphs/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Ffoo"
|
||||
|
||||
async def test_bare_double_dot_thread_id_survives_to_wire(self) -> None:
|
||||
"""The all-dot encoding branch must survive httpx's relative-path collapse."""
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("..")
|
||||
|
||||
assert len(captured) == 1
|
||||
# The all-dot identifier is fully percent-encoded so httpx does NOT
|
||||
# collapse it client-side as a relative-path traversal.
|
||||
assert captured[0].endswith("/threads/%2E%2E")
|
||||
|
||||
async def test_bare_single_dot_thread_id_survives_to_wire(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get(".")
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].endswith("/threads/%2E")
|
||||
|
||||
async def test_uuid_identifier_lands_on_intended_path(self) -> None:
|
||||
"""Legitimate UUID identifiers round-trip without encoding artifacts."""
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
|
||||
|
||||
|
||||
class TestSyncPathEncoding:
|
||||
"""Sync-client tests that mirror the async coverage on a representative subset."""
|
||||
|
||||
def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get("../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Fassistants%2Fabc-123"
|
||||
|
||||
def test_assistants_get_with_pivot_payload_stays_on_assistants(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"assistant_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = SyncAssistantsClient(SyncHttpClient(client))
|
||||
assistants_client.get("../threads/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/assistants/")
|
||||
segment = wire[len("/assistants/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
runs_client = SyncRunsClient(SyncHttpClient(client))
|
||||
runs_client.delete("..", "../runs/crons/cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/threads/")
|
||||
assert "/runs/crons/" not in wire
|
||||
parts = wire.split("/")
|
||||
assert len(parts) == 5
|
||||
assert parts[1] == "threads"
|
||||
assert parts[3] == "runs"
|
||||
assert parts[2] == "%2E%2E"
|
||||
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
|
||||
|
||||
def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
crons_client = SyncCronClient(SyncHttpClient(client))
|
||||
crons_client.delete("../../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/runs/crons/")
|
||||
segment = wire[len("/runs/crons/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
def test_uuid_identifier_lands_on_intended_path(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
|
||||
Generated
+11
-11
@@ -227,11 +227,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -298,7 +298,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -382,7 +382,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.0"
|
||||
version = "4.1.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -551,7 +551,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.31"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -564,9 +564,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/64/95f1f013531395f4e8ed73caeee780f65c7c58fe028cb543f8937b45611b/langsmith-0.8.0.tar.gz", hash = "sha256:59fe5b2a56bbbe14a08aa76691f84b49e8675dd21e11b57d80c6db8c08bac2e3", size = 4432996, upload-time = "2026-04-30T22:13:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1320,11 +1320,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user