Compare commits

...
Author SHA1 Message Date
Christian Bromann 88635b4413 fix(langgraph): treat DeltaChannel Overwrite as a hard reset in update()
DeltaChannel.update() kept writes ordered before an Overwrite within the
same super-step, while replay_writes() dropped them, so reconstructing a
thread from a checkpoint produced different state than the live run. Align
update() with replay_writes() (drop everything up to and including the
overwrite) so reload reproduces live state.
2026-06-01 12:38:33 -07:00
Nick HollonandGitHub 83dd61feac release(langgraph): 1.2.3 (#7945) 2026-06-01 14:51:31 -04:00
Nick HollonandGitHub 13f2ecc84b release(sdk-py): 0.4.2 (#7955) 2026-06-01 13:49:09 -04:00
Nick HollonandGitHub af5dab5b77 fix(sdk-py): percent-encode thread_id in v3 stream transport default paths (#7954)
## Problem

Fixes #7953.

The v3 SSE and WebSocket stream transports build their default paths by
interpolating `thread_id` directly into the URL:

```python
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_url   = stream_path   or f"/threads/{thread_id}/stream/events"
```

This skips the `_quote_path_param` escaping that the rest of the SDK
adopted in #7893. A `thread_id` containing reserved characters or
dot-segments is then normalized by the HTTP/WebSocket stack before
transmission. For example, `thread_id = "../assistants/abc"`:

| | before |
|---|---|
| constructed | `/threads/../assistants/abc/commands` |
| wire path | `/assistants/abc/commands` |

So the value stops being one opaque identifier under
`/threads/{thread_id}/...` and silently hits a different resource.

## Fix

Reuse the existing `_quote_path_param` helper for the **default** paths
in all four transports (`http`, `sync_http`, `ws`, `sync_ws`). Explicit
`commands_path` / `stream_path` overrides are left untouched, so callers
that pass their own paths opt out of encoding as before.

`_quote_path_param("../assistants/abc")` → `..%2Fassistants%2Fabc`,
which the HTTP/WS stack no longer collapses.

## Tests

Adds `tests/streaming/test_transport_path_encoding.py` covering all four
transports:
- default `_commands_url` / `_stream_url` / `_stream_path` are
percent-encoded,
- the actual SSE wire path (async + sync) stays under `/threads/`,
- the built WebSocket URL (async + sync) stays under `/threads/`,
- explicit path overrides are left untouched.

`make format`, `make lint`, and `make test` all pass in `libs/sdk-py`
(491 passed).
2026-06-01 13:20:32 -04:00
Nick HollonandGitHub 312c6d0ac1 feat(langgraph): wire RemoteGraph.interleave to sdk-py interleave_projections (#7938) 2026-06-01 12:42:42 -04:00
14 changed files with 265 additions and 40 deletions
+6 -1
View File
@@ -177,7 +177,12 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
if overwrite_value is not None
else self.typ()
)
remaining = [v for i, v in enumerate(values) if i != overwrite_idx]
# Treat Overwrite as a hard reset: drop everything up to and
# including the overwrite, keeping only writes that follow it. This
# mirrors replay_writes so reconstruction from a checkpoint
# reproduces the live state even when a plain write precedes the
# Overwrite in the same super-step.
remaining = list(values[overwrite_idx + 1 :])
self.value = self.reducer(base, remaining) if remaining else base
return True
base = self.typ() if self.value is MISSING else self.value
@@ -11,6 +11,7 @@ from langchain_core.runnables import RunnableConfig
from langgraph_sdk._async.stream import AsyncThreadStream
from langgraph_sdk._sync.stream import SyncThreadStream
from langgraph_sdk.client import LangGraphClient, SyncLangGraphClient
from langgraph_sdk.stream.decoders import DataDecoder
from langgraph.types import Command
@@ -43,9 +44,10 @@ def _translate_command_input(input: Any) -> Any:
class _ChannelProjection:
"""Decoded projection for a wire channel the SDK doesn't type natively.
Subscribes to `channel` and yields each event's `params["data"]` — the same
item shape the SDK's typed projections yield (`_ValuesProjection` etc.) and
that local's `UpdatesTransformer` / `CheckpointsTransformer` /
Subscribes to `channel` and decodes each event's `params["data"]` through the
SDK's `DataDecoder` — the same decoder the SDK's own plain-payload projections
(`values` / `updates` / `checkpoints` / `tasks`) use, which yields the item
shape that local's `UpdatesTransformer` / `CheckpointsTransformer` /
`TasksTransformer` / `CustomTransformer` push, so iterating this matches the
corresponding local projection. Iterate with `for` against a sync stream and
`async for` against an async stream (matching the underlying SDK). Opening
@@ -56,30 +58,23 @@ class _ChannelProjection:
self._sdk = sdk
self._channel = channel
@staticmethod
def _data(event: Any) -> Any:
"""Extract `params.data` from a protocol event, tolerating odd shapes."""
params = event.get("params") if isinstance(event, dict) else None
return params.get("data") if isinstance(params, dict) else None
def __iter__(self) -> Iterator[Any]:
# Sync lane: the sync adapter's SDK returns a sync iterator here.
decoder = DataDecoder(self._channel)
events = cast(Iterator[Any], self._sdk.subscribe([self._channel]))
for event in events:
data = self._data(event)
if data is not None:
yield data
yield from decoder.feed(event)
def __aiter__(self) -> AsyncIterator[Any]:
return self._aiter()
async def _aiter(self) -> AsyncIterator[Any]:
# Async lane: the async adapter's SDK returns an async iterator here.
decoder = DataDecoder(self._channel)
events = cast(AsyncIterator[Any], self._sdk.subscribe([self._channel]))
async for event in events:
data = self._data(event)
if data is not None:
yield data
for item in decoder.feed(event):
yield item
class _ProjectionRegistry(Mapping[str, Any]):
@@ -243,7 +238,7 @@ class _RemoteGraphRunStream:
return self._events_iter
def interleave(self, *names: str) -> Iterator[tuple[str, Any]]:
raise NotImplementedError
yield from self._sdk.interleave_projections(list(names))
class _AsyncRemoteGraphRunStream:
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.2"
version = "1.2.3"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -26,7 +26,7 @@ classifiers = [
dependencies = [
"langchain-core>=1.4.0,<2",
"langgraph-checkpoint>=4.1.0,<5.0.0",
"langgraph-sdk>=0.4.0,<0.5.0",
"langgraph-sdk>=0.4.2,<0.5.0",
"langgraph-prebuilt>=1.1.0,<1.2.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
+32
View File
@@ -186,6 +186,38 @@ def test_delta_channel_overwrite() -> None:
assert ch.get()[0].content == "new"
def test_delta_channel_overwrite_after_plain_write_in_one_step() -> None:
"""Regression: a plain write ordered BEFORE an Overwrite in the same
super-step must reconstruct identically to the live state.
Overwrite is a hard reset: update() and replay_writes() both drop
everything up to and including the overwrite, keeping only writes that
follow it. Mirrors the JS end-state (channels/delta.ts).
"""
def list_reducer(state: list, writes: list) -> list:
out = list(state)
for w in writes:
out.extend(w)
return out
# Live: a single super-step receives [1] then Overwrite([50]).
live = DeltaChannel(list_reducer, list).from_checkpoint(MISSING)
live.update([[1], Overwrite([50])])
assert live.get() == [50]
# Reload: the same two writes are replayed from the checkpoint.
replayed = DeltaChannel(list_reducer, list).from_checkpoint(MISSING)
replayed.replay_writes(
[
("t1", "messages", [1]),
("t2", "messages", Overwrite([50])),
]
)
# Invariant: reconstructed state must equal live state.
assert replayed.get() == live.get()
def test_delta_channel_remove_message_and_replay() -> None:
"""RemoveMessage must round-trip correctly when writes are replayed."""
spec = DeltaChannel(_messages_delta_reducer, list)
+46 -9
View File
@@ -139,14 +139,15 @@ def test_projection_registry_typed_decoded_and_custom():
def test_channel_projection_decodes_params_data():
sdk = MagicMock()
# Two events with data + one malformed/dataless event that must be skipped.
# Real wire events carry `method`; the SDK `DataDecoder` yields matching
# events' `params.data` and skips dataless and off-channel ones.
sdk.subscribe = MagicMock(
return_value=iter(
[
{"params": {"data": {"n": 1}}},
{"params": {}}, # no data -> skipped
{"params": {"data": {"n": 2}}},
{"unexpected": "shape"}, # not a params dict -> skipped
{"method": "checkpoints", "params": {"data": {"n": 1}}},
{"method": "checkpoints", "params": {}}, # no data -> skipped
{"method": "checkpoints", "params": {"data": {"n": 2}}},
{"method": "lifecycle", "params": {"data": {"n": 3}}}, # other channel
]
)
)
@@ -155,6 +156,39 @@ def test_channel_projection_decodes_params_data():
sdk.subscribe.assert_called_once_with(["checkpoints"])
@pytest.mark.anyio
async def test_channel_projection_decodes_params_data_async():
"""Async lane mirrors the sync lane: `async for` over the SDK's async
subscription, decoded through the same `DataDecoder`."""
class _FakeAsyncEvents:
def __init__(self, items):
self._items = list(items)
def __aiter__(self):
return self
async def __anext__(self):
if not self._items:
raise StopAsyncIteration
return self._items.pop(0)
sdk = MagicMock()
sdk.subscribe = MagicMock(
return_value=_FakeAsyncEvents(
[
{"method": "checkpoints", "params": {"data": {"n": 1}}},
{"method": "checkpoints", "params": {}}, # no data -> skipped
{"method": "checkpoints", "params": {"data": {"n": 2}}},
{"method": "lifecycle", "params": {"data": {"n": 3}}}, # other channel
]
)
)
proj = _ChannelProjection(sdk, "checkpoints")
assert [item async for item in proj] == [{"n": 1}, {"n": 2}]
sdk.subscribe.assert_called_once_with(["checkpoints"])
def test_sync_adapter_translates_command_input():
sync_client = MagicMock()
sdk_thread = MagicMock()
@@ -213,11 +247,14 @@ def test_abort_swallows_cancel_failure_and_still_closes():
sdk_thread.close.assert_called_once()
def test_sync_interleave_raises_not_implemented():
adapter, _, _ = _make_sync_adapter()
def test_sync_interleave_delegates_to_interleave_projections():
adapter, _, sdk_thread = _make_sync_adapter()
pairs = [("values", {"x": 1}), ("messages", object())]
sdk_thread.interleave_projections.return_value = pairs
with adapter as stream:
with pytest.raises(NotImplementedError):
list(stream.interleave("messages"))
result = list(stream.interleave("values", "messages"))
assert result == pairs
sdk_thread.interleave_projections.assert_called_once_with(["values", "messages"])
def test_async_adapter_has_no_interleave():
+1 -1
View File
@@ -1382,7 +1382,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.2"
version = "1.2.3"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -285,7 +285,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.2"
version = "1.2.3"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
+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.4.1"
__version__ = "0.4.2"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
@@ -20,6 +20,7 @@ import httpx
import orjson
from langchain_protocol import Event
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.sse import BytesLineDecoder, SSEDecoder
from langgraph_sdk.stream.transport.base import (
EventStreamHandle,
@@ -49,8 +50,12 @@ class ProtocolSseTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_url = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_url = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._max_queue_size = max_queue_size
self._closed = False
@@ -10,6 +10,7 @@ import httpx
import orjson
from langchain_protocol import Event
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.sse import BytesLineDecoder, SSEDecoder
from langgraph_sdk.stream.transport.base import (
SyncEventStreamHandle,
@@ -31,8 +32,12 @@ class SyncProtocolSseTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_url = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_url = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._closed = False
self._open_responses: list[httpx.Response] = []
@@ -11,6 +11,7 @@ import orjson
from langchain_protocol import Event
from websockets.sync.client import connect as websocket_connect
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.stream.transport.base import (
SyncEventStreamHandle,
build_event_stream_body,
@@ -36,8 +37,12 @@ class SyncProtocolWebSocketTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_path = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_path = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._connect = connect
self._ping_interval = ping_interval
@@ -13,6 +13,7 @@ from langchain_protocol import Event
from websockets.asyncio.client import connect as websocket_connect
from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.stream.transport.base import (
EventStreamHandle,
build_event_stream_body,
@@ -39,8 +40,12 @@ class ProtocolWebSocketTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_path = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_path = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._connect = connect
self._max_queue_size = max_queue_size
@@ -0,0 +1,136 @@
"""Regression tests for #7953: v3 stream transports must percent-encode
`thread_id` in their default paths so a value containing reserved characters
or dot-segments stays an opaque identifier under `/threads/{thread_id}/...`
instead of being normalized into a different resource path by the HTTP stack.
"""
from __future__ import annotations
import httpx
import pytest
from langgraph_sdk.stream.transport.base import build_websocket_url
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport
from langgraph_sdk.stream.transport.sync_ws import SyncProtocolWebSocketTransport
from langgraph_sdk.stream.transport.ws import ProtocolWebSocketTransport
# A thread_id that escapes the /threads/ namespace if interpolated raw: an HTTP
# client collapses `/threads/../assistants/abc/...` to `/assistants/abc/...`.
TRAVERSAL_THREAD_ID = "../assistants/abc"
ENCODED_COMMANDS_PATH = "/threads/..%2Fassistants%2Fabc/commands"
ENCODED_STREAM_PATH = "/threads/..%2Fassistants%2Fabc/stream/events"
@pytest.mark.anyio
async def test_async_sse_default_paths_encode_thread_id():
transport = ProtocolSseTransport(
client=httpx.AsyncClient(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_url == ENCODED_STREAM_PATH
def test_sync_sse_default_paths_encode_thread_id():
transport = SyncProtocolSseTransport(
client=httpx.Client(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_url == ENCODED_STREAM_PATH
@pytest.mark.anyio
async def test_async_ws_default_paths_encode_thread_id():
transport = ProtocolWebSocketTransport(
client=httpx.AsyncClient(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_path == ENCODED_STREAM_PATH
def test_sync_ws_default_paths_encode_thread_id():
transport = SyncProtocolWebSocketTransport(
client=httpx.Client(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_path == ENCODED_STREAM_PATH
async def test_async_sse_wire_path_stays_under_threads_namespace():
"""The path that actually goes on the wire must not be normalized away."""
seen: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.url.raw_path.decode("ascii"))
return httpx.Response(202)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://example.com",
trust_env=False,
) as client:
transport = ProtocolSseTransport(client=client, thread_id=TRAVERSAL_THREAD_ID)
await transport.send_command({"id": 1, "method": "noop", "params": {}})
assert seen[0] == ENCODED_COMMANDS_PATH
def test_sync_sse_wire_path_stays_under_threads_namespace():
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.url.raw_path.decode("ascii"))
return httpx.Response(202)
with httpx.Client(
transport=httpx.MockTransport(handler),
base_url="https://example.com",
trust_env=False,
) as client:
transport = SyncProtocolSseTransport(
client=client, thread_id=TRAVERSAL_THREAD_ID
)
transport.send_command({"id": 1, "method": "noop", "params": {}})
assert seen[0] == ENCODED_COMMANDS_PATH
@pytest.mark.anyio
async def test_async_ws_url_stays_under_threads_namespace():
transport = ProtocolWebSocketTransport(
client=httpx.AsyncClient(base_url="https://example.com/api"),
thread_id=TRAVERSAL_THREAD_ID,
)
url = build_websocket_url(transport._client.base_url, transport._stream_path)
assert url == "wss://example.com/api/threads/..%2Fassistants%2Fabc/stream/events"
def test_sync_ws_url_stays_under_threads_namespace():
transport = SyncProtocolWebSocketTransport(
client=httpx.Client(base_url="https://example.com/api"),
thread_id=TRAVERSAL_THREAD_ID,
)
url = build_websocket_url(transport._client.base_url, transport._stream_path)
assert url == "wss://example.com/api/threads/..%2Fassistants%2Fabc/stream/events"
@pytest.mark.anyio
async def test_explicit_path_overrides_are_left_untouched():
"""Callers passing explicit paths opt out of default encoding entirely."""
sse = ProtocolSseTransport(
client=httpx.AsyncClient(),
thread_id=TRAVERSAL_THREAD_ID,
commands_path="/custom/commands",
stream_path="/custom/events",
)
assert sse._commands_url == "/custom/commands"
assert sse._stream_url == "/custom/events"
ws = ProtocolWebSocketTransport(
client=httpx.AsyncClient(),
thread_id=TRAVERSAL_THREAD_ID,
commands_path="/custom/commands",
stream_path="/custom/events",
)
assert ws._commands_url == "/custom/commands"
assert ws._stream_path == "/custom/events"
+1 -1
View File
@@ -298,7 +298,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.2"
version = "1.2.3"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },