mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-29 03:09:45 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b82d380634 | ||
|
|
afdfbd55da | ||
|
|
15113c0f60 |
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+2
-2
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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" },
|
||||
|
||||
Generated
+2
-2
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user