Compare commits

..
Author SHA1 Message Date
Nick HollonandGitHub b82d380634 release: alpha bump prebuilt 1.1.0a2, langgraph 1.2.0a5 (#7682) 2026-05-01 13:56:21 -04:00
afdfbd55da fix(langgraph): coerce dict/str writes in _messages_delta_reducer (#7680)
## Problem

`_messages_delta_reducer` assumed writes always contain pre-typed
`BaseMessage` objects (as noted in its docstring). In practice,
HTTP-driven graphs always receive message input as JSON dicts — the same
way `add_messages` receives them. Using
`DeltaChannel(_messages_delta_reducer)` with any HTTP input would crash
with:

```
AttributeError: 'dict' object has no attribute 'id'
```

This makes `_messages_delta_reducer` unusable for the primary motivating
use-case (replacing `add_messages` in production LLM graphs).

## Fix

Mirror the coercion contract of `add_messages`:
- Regular message dicts (`{"role": "human", "content": "..."}`) →
`convert_to_messages`
- `RemoveMessage` dicts (`{"type": "remove", "id": "..."}`) →
`RemoveMessage` directly (langchain_core's `convert_to_messages` doesn't
support this format)
- `BaseMessage` objects → pass through unchanged
- Lists/sequences → element-wise coercion of the above

The fix is a small `_coerce_one` + `_to_msgs` helper pair that replaces
the previous `[w] if isinstance(w, BaseMessage) else w` generator.

## Tests

Added `test_delta_channel_dict_coercion` in `test_channels.py` covering:
- dict append via `{"role": "human", "content": ..., "id": ...}`
- dict update-in-place (same ID)
- `{"type": "remove", "id": ...}` tombstoning

All 23 existing delta-channel tests still pass.

Release Notes: None

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:56:01 -04:00
Nick HollonandGitHub 15113c0f60 fix(prebuilt): scope ToolCallTransformer projection to its own namespace (#7681) 2026-05-01 13:29:42 -04:00
9 changed files with 167 additions and 20 deletions
+29 -10
View File
@@ -250,14 +250,16 @@ def _messages_delta_reducer(
"""**Experimental.** Batch reducer for use with `DeltaChannel`.
Processes all writes in one pass — dedup by ID, `RemoveMessage`
tombstoning — without calling `add_messages`. Assumes writes contain
already-typed `BaseMessage` objects (no raw-dict coercion).
tombstoning — without calling `add_messages`.
This reducer is batching-invariant, as required by `DeltaChannel`:
`reducer(reducer(state, xs), ys) == reducer(state, xs + ys)`.
Use `add_messages` as the reducer for `BinaryOperatorAggregate` or
anywhere raw message dicts / strings need to be coerced first.
Raw dict / string / tuple inputs are coerced to typed `BaseMessage`
objects so that HTTP-driven graphs work without a separate coercion
step. This is not full `add_messages` parity — `REMOVE_ALL_MESSAGES`,
unknown-id `RemoveMessage` errors, missing-id UUID assignment, and
`BaseMessageChunk` conversion are not handled here.
Example::
@@ -268,13 +270,30 @@ def _messages_delta_reducer(
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
"""
from itertools import chain
index: dict[str, int] = {m.id: i for i, m in enumerate(state) if m.id is not None}
result: list[AnyMessage | None] = list(state)
for msg in chain.from_iterable(
[w] if isinstance(w, BaseMessage) else w for w in writes
):
# Each write is either a list of message-likes or a single message-like
# (BaseMessage / dict / str / tuple). Only lists flatten; everything
# else is one message.
flat: list[Any] = []
for w in writes:
if isinstance(w, list):
flat.extend(w)
else:
flat.append(w)
# Steady state: the reducer's own output is already typed, so skip
# `convert_to_messages` on state when the first element is a BaseMessage.
# Only raw input (initial dicts, deserialized blobs) hits the slow path.
if state and isinstance(state[0], BaseMessage):
state_msgs = state
else:
state_msgs = cast("list[AnyMessage]", convert_to_messages(state))
msgs = cast("list[AnyMessage]", convert_to_messages(flat))
index: dict[str, int] = {
m.id: i for i, m in enumerate(state_msgs) if m.id is not None
}
result: list[AnyMessage | None] = list(state_msgs)
for msg in msgs:
mid = msg.id
if mid is None:
result.append(msg)
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.0a4"
version = "1.2.0a5"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -27,7 +27,7 @@ dependencies = [
"langchain-core>=1.4.0a2,<2",
"langgraph-checkpoint>=4.1.0a3,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.0",
"langgraph-prebuilt>=1.1.0a1,<1.2.0",
"langgraph-prebuilt>=1.1.0a2,<1.2.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
]
+52
View File
@@ -233,6 +233,58 @@ def test_delta_channel_update_by_id_and_replay() -> None:
assert ch2.get()[0].content == "updated"
def test_delta_channel_dict_coercion() -> None:
"""_messages_delta_reducer coerces dict writes to BaseMessage objects.
HTTP-driven input always arrives as JSON dicts. The reducer must coerce
them (same contract as add_messages) so graphs work without a separate
coercion step.
"""
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
# dict input — simulates what arrives from the HTTP API
ch.update([{"role": "human", "content": "hello", "id": "h1"}])
assert len(ch.get()) == 1
assert isinstance(ch.get()[0], HumanMessage)
assert ch.get()[0].content == "hello"
assert ch.get()[0].id == "h1"
# update by ID via dict
ch.update([{"role": "ai", "content": "world", "id": "h1"}])
assert len(ch.get()) == 1
assert ch.get()[0].content == "world"
# remove via RemoveMessage instance (same contract as add_messages)
ch.update([RemoveMessage(id="h1")])
assert ch.get() == []
def test_messages_delta_reducer_coerces_state() -> None:
"""State (left side) is coerced when raw — supports raw initial input
and deserialized blobs. The steady-state path (state already typed)
short-circuits and skips coercion.
"""
state = [{"role": "human", "content": "hello", "id": "h1"}]
writes = [[{"role": "ai", "content": "world", "id": "h1"}]]
result = _messages_delta_reducer(state, writes) # type: ignore[arg-type]
assert len(result) == 1
assert isinstance(result[0], AIMessage)
assert result[0].content == "world"
assert result[0].id == "h1"
def test_messages_delta_reducer_tuple_write_is_one_message() -> None:
"""A top-level tuple write is one message-like, not a sequence to flatten.
`("user", "hi")` is a valid `MessageLikeRepresentation`; flattening it
would produce two HumanMessages ("user", "hi") instead of one.
"""
result = _messages_delta_reducer([], [("user", "hi")]) # type: ignore[arg-type]
assert len(result) == 1
assert isinstance(result[0], HumanMessage)
assert result[0].content == "hi"
def test_delta_channel_checkpoint_returns_sentinel() -> None:
"""checkpoint() always returns DELTA_SENTINEL regardless of state."""
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
+2 -2
View File
@@ -1380,7 +1380,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.0a4"
version = "1.2.0a5"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1755,7 +1755,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.0a1"
version = "1.1.0a2"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -77,10 +77,16 @@ class ToolCallTransformer(StreamTransformer):
return stream
def process(self, event: ProtocolEvent) -> bool:
# Namespace filtering is handled by the mux via `scope_exact`.
if event["method"] != "tools":
return True
# Only project events emitted at this transformer's scope. Subgraph
# events still flow through the parent's mux (the parent's main
# event log keeps them) but they belong to the child mini-mux's
# `tool_calls` projection, not the parent's.
if tuple(event["params"]["namespace"]) != self.scope:
return True
data = event["params"]["data"]
tool_call_id = data.get("tool_call_id")
if tool_call_id is None:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.1.0a1"
version = "1.1.0a2"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
@@ -160,6 +160,76 @@ class TestToolCallTransformerUnit:
kept = [e for e in _unstamped(mux._events._items) if e["method"] == "tools"]
assert len(kept) == 1
def test_out_of_scope_event_skipped(self) -> None:
"""Subgraph-scoped `tools` events must not project into a parent
transformer's `tool_calls` log.
The parent's main event log keeps the event (so wire consumers
still see it) but the parent's `ToolCallTransformer` only owns
the projection at its own scope. Per-scope `ToolCallTransformer`
instances on child mini-muxes are responsible for projecting
events at their own depth.
"""
# Root-scope transformer (`scope == ()`).
mux, transformer = _mux()
_subscribe(mux._events)
mux.push(
_tool_event(
"tool-started",
"tc1",
tool_name="inner_echo",
namespace=["child:abc"],
)
)
# No `ToolCallStream` was projected into the root's log.
assert _unstamped(transformer._log._items) == []
assert "tc1" not in transformer._active
# The event still passes through the main event log so consumers
# of the raw `tools` channel see it untouched.
kept = [e for e in _unstamped(mux._events._items) if e["method"] == "tools"]
assert len(kept) == 1
def test_in_scope_event_projected_when_scope_set(self) -> None:
"""A non-root transformer projects only events at its own scope."""
scope: tuple[str, ...] = ("child:abc",)
transformer = ToolCallTransformer(scope=scope)
mux = StreamMux(
[ValuesTransformer(), MessagesTransformer(), transformer],
scope=scope,
is_async=False,
)
_subscribe(transformer._log)
# Event at this scope: projected.
mux.push(
_tool_event(
"tool-started",
"tc1",
tool_name="echo",
namespace=list(scope),
)
)
assert len(_unstamped(transformer._log._items)) == 1
# Event at a deeper scope: ignored.
mux.push(
_tool_event(
"tool-started",
"tc2",
tool_name="grandchild",
namespace=[*scope, "grand:xyz"],
)
)
assert len(_unstamped(transformer._log._items)) == 1
# Event at root (above this scope): ignored.
mux.push(
_tool_event(
"tool-started",
"tc3",
tool_name="root_tool",
namespace=[],
)
)
assert len(_unstamped(transformer._log._items)) == 1
# ---------------------------------------------------------------------------
# End-to-end tests with a real graph
+2 -2
View File
@@ -281,7 +281,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.0a4"
version = "1.2.0a5"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -503,7 +503,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.0a1"
version = "1.1.0a2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
+2 -2
View File
@@ -298,7 +298,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.0a4"
version = "1.2.0a5"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -430,7 +430,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.1.0a1"
version = "1.1.0a2"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },