From 1cf08abd922be6c8576ef63cc068f03b876d76ab Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 12 May 2026 07:38:45 -0700 Subject: [PATCH] feat(core)!: auto-assign UUID to BaseMessage.id at creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the `id` field from `default=None` to `default_factory=lambda: str(uuid.uuid4())` so every message receives a stable, unique id from birth rather than being id-less until a reducer or `add_messages` assigns one. ## Motivation LangGraph's `DeltaChannel` stores pending writes (the raw message objects) as serialized blobs **before** `update()` is called. Any id assigned inside the reducer (e.g. a random UUID in `_messages_delta_reducer`) only exists in the in-memory channel state; it never reaches the stored write. On checkpoint replay the same id-less message gets a fresh random UUID, so an eviction/update Command that references the runtime-assigned id cannot match the replayed message — both the original and the update land in state as separate messages. The root fix is here: if messages already carry a stable id when they are first constructed (before any serialization boundary), the stored write and any subsequent Command that updates that message by id will always agree on the id, making `DeltaChannel` replay fully correct. ## Breaking change `HumanMessage(content="x") == HumanMessage(content="x")` is now `False` because auto-assigned UUIDs differ. Previously both had `id=None` and compared equal. Code that relies on content-based message equality must be updated (compare `.content` / `.model_dump(exclude={'id'})` directly, or set an explicit shared `id`). 129 unit tests in langchain-core fail; all are due to this equality change. Options the team can consider: - Fix tests to use explicit ids or field-level comparisons - Override `BaseMessage.__eq__` to exclude `id` (preserves backward compat for equality; DeltaChannel dedup uses id-keyed dict, not ==) - Introduce a `__eq__` that only uses `id` when both sides have a non-None id, otherwise falls back to content comparison Explicitly passing `id=None` still produces a message with `id=None` (default_factory is not invoked for explicitly-supplied values). --- libs/core/langchain_core/messages/base.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/libs/core/langchain_core/messages/base.py b/libs/core/langchain_core/messages/base.py index 2b0e998c70f..ea8f1544cc4 100644 --- a/libs/core/langchain_core/messages/base.py +++ b/libs/core/langchain_core/messages/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import uuid from typing import TYPE_CHECKING, Any, cast, overload from pydantic import ConfigDict, Field @@ -132,10 +133,22 @@ class BaseMessage(Serializable): """ - id: str | None = Field(default=None, coerce_numbers_to_str=True) - """An optional unique identifier for the message. + id: str | None = Field( + default_factory=lambda: str(uuid.uuid4()), + coerce_numbers_to_str=True, + ) + """A unique identifier for the message. - This should ideally be provided by the provider/model which created the message. + Auto-assigned as a UUID on creation when not provided. The id is used by + LangGraph's ``DeltaChannel`` reducer and other components to deduplicate + messages: a message written to a channel can be updated in-place on + subsequent writes because it carries a stable id from birth. Without an id + the deduplication logic cannot match a message to its prior version across + checkpoint serialization boundaries, causing duplicates. + + The type is ``str | None`` rather than ``str`` so that existing serialized + messages without an ``id`` field deserialize cleanly (they get ``None`` + back, not an error). New messages created at runtime always receive a UUID. """