Compare commits

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