This commit is contained in:
Josh Rogers
2026-02-09 15:20:45 -05:00
parent dd17e7fd6b
commit 7b0334bdb4
2 changed files with 16 additions and 533 deletions
+16 -28
View File
@@ -84,19 +84,10 @@ def add_messages(
Must have `langchain-core>=0.3.11` installed to use this feature.
mode: Controls how message updates and removals are handled. Options are:
- `allow_everything` (default): Allows adding new messages, updating
existing messages by ID, and removing messages. This is the standard behavior.
- `append_only`: Only allows adding new messages. If a message in `right`
has the same ID as a message in `left` (update or removal), a `ValueError`
will be raised. This mode is useful when you want to prevent any modification
of message history.
Returns:
A new list of messages with the messages from `right` merged into `left`.
If a message in `right` has the same ID as a message in `left`, the
message from `right` will replace the message from `left` (in
`allow_everything` mode) or raise a `ValueError` (in `append_only` mode).
message from `right` will replace the message from `left`.
Example: Basic usage
```python
@@ -310,12 +301,8 @@ def validate_messages_append_only(
if not isinstance(input_messages, list):
input_messages = [input_messages]
# Convert to messages to get proper IDs
try:
converted_messages = convert_to_messages(input_messages)
except Exception:
# If conversion fails, let it through - the reducer will handle it
return
# Convert to messages to get proper IDs (let conversion errors propagate)
converted_messages = convert_to_messages(input_messages)
# Check each input message
for msg in converted_messages:
@@ -326,18 +313,19 @@ def validate_messages_append_only(
"External inputs must only append new messages."
)
# Check for updates or removals of existing messages
if msg.id and msg.id in existing_ids:
if isinstance(msg, RemoveMessage):
raise ValueError(
f"Cannot remove existing message with ID '{msg.id}' in append_only mode. "
"External inputs must only append new messages."
)
else:
raise ValueError(
f"Cannot update existing message with ID '{msg.id}' in append_only mode. "
"External inputs must only append new messages."
)
# Check for removals of existing messages
if isinstance(msg, RemoveMessage) and msg.id in existing_ids:
raise ValueError(
f"Cannot remove existing message with ID '{msg.id}' in append_only mode. "
"External inputs must only append new messages."
)
# Check for updates of existing messages
if not isinstance(msg, RemoveMessage) and msg.id and msg.id in existing_ids:
raise ValueError(
f"Cannot update existing message with ID '{msg.id}' in append_only mode. "
"External inputs must only append new messages."
)
@deprecated(
@@ -1,505 +0,0 @@
from typing import Annotated
from uuid import UUID
import langchain_core
import pytest
from langchain_core.messages import (
AIMessage,
AnyMessage,
HumanMessage,
RemoveMessage,
SystemMessage,
ToolMessage,
)
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph.constants import END, START
from langgraph.graph import add_messages
from langgraph.graph.message import REMOVE_ALL_MESSAGES, MessagesState, push_message
from langgraph.graph.state import StateGraph
from tests.messages import _AnyIdHumanMessage
_, CORE_MINOR, CORE_PATCH = (
int("".join(c for c in v if c.isdigit()))
for v in langchain_core.__version__.split(".")
)
def test_add_single_message():
left = [HumanMessage(content="Hello", id="1")]
right = AIMessage(content="Hi there!", id="2")
result = add_messages(left, right)
expected_result = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
assert result == expected_result
def test_add_multiple_messages():
left = [HumanMessage(content="Hello", id="1")]
right = [
AIMessage(content="Hi there!", id="2"),
SystemMessage(content="System message", id="3"),
]
result = add_messages(left, right)
expected_result = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
SystemMessage(content="System message", id="3"),
]
assert result == expected_result
def test_update_existing_message():
left = [HumanMessage(content="Hello", id="1")]
right = HumanMessage(content="Hello again", id="1")
result = add_messages(left, right)
expected_result = [HumanMessage(content="Hello again", id="1")]
assert result == expected_result
def test_missing_ids():
left = [HumanMessage(content="Hello")]
right = [AIMessage(content="Hi there!")]
result = add_messages(left, right)
assert len(result) == 2
assert all(isinstance(m.id, str) and UUID(m.id, version=4) for m in result)
def test_duplicates_in_input():
left = []
right = [
AIMessage(id="1", content="Hi there!"),
AIMessage(id="1", content="Hi there again!"),
]
result = add_messages(left, right)
assert len(result) == 1
assert result[0].id == "1"
assert result[0].content == "Hi there again!"
def test_duplicates_in_input_with_remove():
left = [AIMessage(id="1", content="Hello!")]
right = [
RemoveMessage(id="1"),
AIMessage(id="1", content="Hi there!"),
AIMessage(id="1", content="Hi there again!"),
]
result = add_messages(left, right)
assert len(result) == 1
assert result[0].id == "1"
assert result[0].content == "Hi there again!"
def test_remove_message():
left = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
right = RemoveMessage(id="2")
result = add_messages(left, right)
expected_result = [HumanMessage(content="Hello", id="1")]
assert result == expected_result
def test_duplicate_remove_message():
left = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
right = [RemoveMessage(id="2"), RemoveMessage(id="2")]
result = add_messages(left, right)
expected_result = [HumanMessage(content="Hello", id="1")]
assert result == expected_result
def test_remove_nonexistent_message():
left = [HumanMessage(content="Hello", id="1")]
right = RemoveMessage(id="2")
with pytest.raises(
ValueError, match="Attempting to delete a message with an ID that doesn't exist"
):
add_messages(left, right)
def test_mixed_operations():
left = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
right = [
HumanMessage(content="Updated hello", id="1"),
RemoveMessage(id="2"),
SystemMessage(content="New message", id="3"),
]
result = add_messages(left, right)
expected_result = [
HumanMessage(content="Updated hello", id="1"),
SystemMessage(content="New message", id="3"),
]
assert result == expected_result
def test_empty_inputs():
assert add_messages([], []) == []
assert add_messages([], [HumanMessage(content="Hello", id="1")]) == [
HumanMessage(content="Hello", id="1")
]
assert add_messages([HumanMessage(content="Hello", id="1")], []) == [
HumanMessage(content="Hello", id="1")
]
def test_non_list_inputs():
left = HumanMessage(content="Hello", id="1")
right = AIMessage(content="Hi there!", id="2")
result = add_messages(left, right)
expected_result = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
assert result == expected_result
def test_delete_all():
left = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
right = [
RemoveMessage(id="1"),
RemoveMessage(id="2"),
]
result = add_messages(left, right)
expected_result = []
assert result == expected_result
class MessagesStatePydantic(BaseModel):
messages: Annotated[list[AnyMessage], add_messages]
MESSAGES_STATE_SCHEMAS = [MessagesState, MessagesStatePydantic]
@pytest.mark.parametrize("state_schema", MESSAGES_STATE_SCHEMAS)
def test_messages_state(state_schema):
def foo(state):
return {"messages": [HumanMessage("foo")]}
graph = StateGraph(state_schema)
graph.add_edge(START, "foo")
graph.add_edge("foo", END)
graph.add_node(foo)
app = graph.compile()
assert app.invoke({"messages": [("user", "meow")]}) == {
"messages": [
_AnyIdHumanMessage(content="meow"),
_AnyIdHumanMessage(content="foo"),
]
}
@pytest.mark.skipif(
condition=not ((CORE_MINOR == 3 and CORE_PATCH >= 11) or CORE_MINOR > 3),
reason="Requires langchain_core>=0.3.11.",
)
def test_messages_state_format_openai():
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages(format="langchain-openai")]
def foo(state):
messages = [
HumanMessage(
content=[
{
"type": "text",
"text": "Here's an image:",
"cache_control": {"type": "ephemeral"},
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": "1234",
},
},
]
),
AIMessage(
content=[
{
"type": "tool_use",
"name": "foo",
"input": {"bar": "baz"},
"id": "1",
}
]
),
HumanMessage(
content=[
{
"type": "tool_result",
"tool_use_id": "1",
"is_error": False,
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": "1234",
},
},
],
}
]
),
]
return {"messages": messages}
expected = [
HumanMessage(content="meow"),
HumanMessage(
content=[
{"type": "text", "text": "Here's an image:"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,1234"},
},
],
),
AIMessage(
content="",
tool_calls=[
{
"name": "foo",
"type": "tool_calls",
"args": {"bar": "baz"},
"id": "1",
}
],
),
ToolMessage(
content=[
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,1234"},
}
],
tool_call_id="1",
),
]
graph = StateGraph(State)
graph.add_edge(START, "foo")
graph.add_edge("foo", END)
graph.add_node(foo)
app = graph.compile()
result = app.invoke({"messages": [("user", "meow")]})
for m in result["messages"]:
m.id = None
assert result == {"messages": expected}
def test_remove_all_messages():
# simple removal
left = [HumanMessage(content="Hello"), AIMessage(content="Hi there!")]
right = [RemoveMessage(id=REMOVE_ALL_MESSAGES)]
result = add_messages(left, right)
assert result == []
# removal and update (i.e., overwriting)
left = [HumanMessage(content="Hello"), AIMessage(content="Hi there!")]
right = [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
HumanMessage(content="Updated hello"),
]
result = add_messages(left, right)
assert result == [_AnyIdHumanMessage(content="Updated hello")]
# test removing preceding messages in the right list
left = [HumanMessage(content="Hello"), AIMessage(content="Hi there!")]
right = [
HumanMessage(content="Updated hello"),
RemoveMessage(id=REMOVE_ALL_MESSAGES),
HumanMessage(content="Updated hi there"),
]
result = add_messages(left, right)
assert result == [
_AnyIdHumanMessage(content="Updated hi there"),
]
def test_push_messages_in_graph():
class MessagesState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
def chat(_: MessagesState) -> MessagesState:
with pytest.raises(ValueError, match="Message ID is required"):
push_message(AIMessage(content="No ID"))
push_message(AIMessage(content="First", id="1"))
push_message(HumanMessage(content="Second", id="2"))
push_message(AIMessage(content="Third", id="3"))
builder = StateGraph(MessagesState)
builder.add_node(chat)
builder.add_edge(START, "chat")
graph = builder.compile()
messages, values = [], None
for event, chunk in graph.stream(
{"messages": []}, stream_mode=["messages", "values"]
):
if event == "values":
values = chunk
elif event == "messages":
message, _ = chunk
messages.append(message)
assert values["messages"] == messages
def test_append_only_mode_allows_new_messages():
"""Test that append_only mode allows adding new messages."""
left = [HumanMessage(content="Hello", id="1")]
right = [AIMessage(content="Hi there!", id="2")]
result = add_messages(left, right, mode="append_only")
expected = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
assert result == expected
def test_append_only_mode_prevents_updates():
"""Test that append_only mode raises an error when trying to update an existing message."""
left = [HumanMessage(content="Hello", id="1")]
right = [HumanMessage(content="Hello again", id="1")]
with pytest.raises(
ValueError,
match="Cannot update existing message with ID '1' in append_only mode",
):
add_messages(left, right, mode="append_only")
def test_append_only_mode_prevents_remove_messages():
"""Test that append_only mode prevents RemoveMessage operations."""
left = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
right = [RemoveMessage(id="2")]
with pytest.raises(
ValueError,
match="Cannot remove existing message with ID '2' in append_only mode",
):
add_messages(left, right, mode="append_only")
def test_append_only_mode_prevents_remove_all_messages():
"""Test that append_only mode prevents REMOVE_ALL_MESSAGES operations."""
left = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
]
right = [RemoveMessage(id=REMOVE_ALL_MESSAGES)]
with pytest.raises(
ValueError,
match="Cannot remove all messages in append_only mode",
):
add_messages(left, right, mode="append_only")
def test_append_only_mode_with_multiple_new_messages():
"""Test that append_only mode allows adding multiple new messages."""
left = [HumanMessage(content="Hello", id="1")]
right = [
AIMessage(content="Hi there!", id="2"),
SystemMessage(content="System message", id="3"),
HumanMessage(content="Another message", id="4"),
]
result = add_messages(left, right, mode="append_only")
expected = [
HumanMessage(content="Hello", id="1"),
AIMessage(content="Hi there!", id="2"),
SystemMessage(content="System message", id="3"),
HumanMessage(content="Another message", id="4"),
]
assert result == expected
def test_append_only_mode_with_mixed_operations():
"""Test that append_only mode fails when mixing new messages with updates."""
left = [HumanMessage(content="Hello", id="1")]
right = [
AIMessage(content="Hi there!", id="2"), # new message
HumanMessage(content="Updated hello", id="1"), # update attempt
]
with pytest.raises(
ValueError,
match="Cannot update existing message with ID '1' in append_only mode",
):
add_messages(left, right, mode="append_only")
def test_allow_everything_mode_default_behavior():
"""Test that allow_everything mode is the default and allows updates."""
left = [HumanMessage(content="Hello", id="1")]
right = [HumanMessage(content="Hello again", id="1")]
# Test without specifying mode (should default to allow_everything)
result = add_messages(left, right)
expected = [HumanMessage(content="Hello again", id="1")]
assert result == expected
# Test with explicit mode
result = add_messages(left, right, mode="allow_everything")
assert result == expected
def test_append_only_mode_in_state_graph():
"""Test append_only mode works correctly in a StateGraph."""
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages(mode="append_only")]
def add_message(state: State):
return {"messages": [AIMessage(content="Response", id="2")]}
def try_update_message(state: State):
# This should fail because message with id="1" already exists
return {"messages": [HumanMessage(content="Updated", id="1")]}
# Test successful case
builder = StateGraph(State)
builder.add_node("add_message", add_message)
builder.add_edge(START, "add_message")
builder.add_edge("add_message", END)
graph = builder.compile()
result = graph.invoke({"messages": [HumanMessage(content="Hello", id="1")]})
assert len(result["messages"]) == 2
assert result["messages"][0].content == "Hello"
assert result["messages"][1].content == "Response"
# Test failure case
builder2 = StateGraph(State)
builder2.add_node("try_update", try_update_message)
builder2.add_edge(START, "try_update")
builder2.add_edge("try_update", END)
graph2 = builder2.compile()
with pytest.raises(
ValueError,
match="Cannot update existing message with ID '1' in append_only mode",
):
graph2.invoke({"messages": [HumanMessage(content="Hello", id="1")]})