fix(sdk): assign UUIDs to ID-less messages in _messages_delta_reducer (#3513)

## Summary

- `_messages_delta_reducer` now assigns `str(uuid.uuid4())` to messages
with `id=None` inside the existing write-loop iteration (no extra pass),
matching the behaviour of `add_messages`
- Version bumped `0.6.2 → 0.6.3`
- `uv lock` refreshed across all `libs/` and `examples/` packages

## Test plan

- [ ] Confirm messages written without an explicit ID arrive in state
with a stable UUID
- [ ] Confirm deduplication still works for messages that already carry
an ID
- [ ] Confirm `RemoveMessage` tombstoning still works correctly
This commit is contained in:
Sydney Runkle
2026-05-20 17:07:37 -04:00
committed by GitHub
parent 593b82d41b
commit 6d959ade30
2 changed files with 33 additions and 7 deletions
@@ -10,6 +10,7 @@ side — so we skip the per-message coercion.
from __future__ import annotations
import uuid
from typing import Any, cast
from langchain_core.messages import (
@@ -21,16 +22,14 @@ from langchain_core.messages import (
from langgraph.graph.message import REMOVE_ALL_MESSAGES
def _messages_delta_reducer( # noqa: C901
def _messages_delta_reducer( # noqa: C901, PLR0912
state: list[AnyMessage], writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
"""Batch reducer for use with `DeltaChannel` on the messages key.
Dedups by ID, tombstones via `RemoveMessage`, resets on
`REMOVE_ALL_MESSAGES`. ID-less messages are appended without ID
assignment — checkpointers serialize pending writes before
`update()` runs, so IDs assigned inside the reducer never reach
stored writes and would differ on replay, defeating deduplication.
`REMOVE_ALL_MESSAGES`. ID-less messages are assigned a UUID before
being appended, matching the behaviour of `add_messages`.
Raw dict / string / tuple inputs are coerced to typed `BaseMessage` so
HTTP-driven graphs work without a separate coercion step.
@@ -60,11 +59,19 @@ def _messages_delta_reducer( # noqa: C901
state_msgs = []
msgs = msgs[remove_all_idx + 1 :]
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)
result: list[AnyMessage | None] = []
index: dict[str, int] = {}
for m in state_msgs:
if m.id is None:
m.id = str(uuid.uuid4())
index[m.id] = len(result)
result.append(m)
for msg in msgs:
mid = msg.id
if mid is None:
msg.id = str(uuid.uuid4())
mid = msg.id
index[mid] = len(result)
result.append(msg)
elif isinstance(msg, RemoveMessage):
if mid in index:
@@ -0,0 +1,19 @@
"""Unit tests for _messages_delta_reducer."""
from __future__ import annotations
from langchain_core.messages import AIMessage, HumanMessage
from deepagents._messages_reducer import _messages_delta_reducer
def test_idless_message_gets_id_and_order_preserved() -> None:
existing = [AIMessage(content="hello", id="existing-1")]
new_msg = HumanMessage(content="follow-up") # no id
result = _messages_delta_reducer(existing, [[new_msg]])
assert len(result) == 2
assert result[0].id == "existing-1"
assert result[1] is new_msg
assert result[1].id is not None