mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(sdk): handle None state in messages delta reducer (#3636)
Fixes #3564 --- `DeltaChannel.replay_writes` can pass `state=None` when a thread's earliest checkpoint never seeded `messages: []`. The reducer now treats `None` as an empty list, preventing `convert_to_messages` from receiving a `None` argument. Regression tests for langchain-ai/deepagents#3564 included. --- Note: intentionally diverging from upstream, will port back later
This commit is contained in:
@@ -29,7 +29,7 @@ from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||
|
||||
|
||||
def _messages_delta_reducer( # noqa: C901, PLR0912
|
||||
state: list[AnyMessage], writes: list[list[AnyMessage]]
|
||||
state: list[AnyMessage] | None, writes: list[list[AnyMessage]]
|
||||
) -> list[AnyMessage]:
|
||||
"""Batch reducer for use with `DeltaChannel` on the messages key.
|
||||
|
||||
@@ -51,8 +51,11 @@ def _messages_delta_reducer( # noqa: C901, PLR0912
|
||||
flat.append(w)
|
||||
# Steady state: the reducer's own output is already typed BaseMessages,
|
||||
# so skip convert_to_messages on the fast path. Only raw input (initial
|
||||
# dicts, deserialized blobs) hits the slow path.
|
||||
state_msgs = state if state and isinstance(state[0], BaseMessage) else cast("list[AnyMessage]", convert_to_messages(state))
|
||||
# dicts, deserialized blobs) hits the slow path. `state` is `None` on
|
||||
# `DeltaChannel.replay_writes` for threads whose earliest checkpoint did
|
||||
# not seed `messages: []`; treat that as the empty list so the slow path
|
||||
# doesn't pass `None` into `convert_to_messages`.
|
||||
state_msgs = state if state and isinstance(state[0], BaseMessage) else cast("list[AnyMessage]", convert_to_messages(state or []))
|
||||
msgs = cast("list[AnyMessage]", convert_to_messages(flat))
|
||||
|
||||
# REMOVE_ALL_MESSAGES resets everything; find the last sentinel and
|
||||
|
||||
@@ -91,6 +91,31 @@ def test_human_message_id_stable_across_invocations_sync() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_reducer_handles_none_base_state() -> None:
|
||||
"""`DeltaChannel.replay_writes` passes `state=None` when the earliest
|
||||
checkpoint for a thread did not seed `messages: []`. The reducer must
|
||||
treat that the same as an empty base.
|
||||
|
||||
Regression test for https://github.com/langchain-ai/deepagents/issues/3564.
|
||||
""" # noqa: D205
|
||||
msg = HumanMessage(content="hi", id="h1")
|
||||
result = _messages_delta_reducer(None, [[msg]])
|
||||
assert result == [msg]
|
||||
|
||||
# Empty writes against a None base should also not crash.
|
||||
assert _messages_delta_reducer(None, []) == []
|
||||
assert _messages_delta_reducer(None, [[]]) == []
|
||||
|
||||
|
||||
def test_reducer_handles_none_base_state_with_dict_messages() -> None:
|
||||
"""None-base replay still coerces raw over-the-wire message payloads."""
|
||||
result = _messages_delta_reducer(None, [[{"role": "user", "content": "hi"}]])
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], HumanMessage)
|
||||
assert result[0].content == "hi"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_human_message_id_stable_across_invocations_async() -> None:
|
||||
"""Same check via ainvoke (AsyncPregelLoop path)."""
|
||||
|
||||
Reference in New Issue
Block a user