mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-26 17:42:24 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7735fb0b2 |
@@ -306,7 +306,12 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
|
||||
for k, v in config.items():
|
||||
if _is_not_empty(v) and k in CONFIG_KEYS:
|
||||
if k == CONF:
|
||||
empty[k] = cast(dict, v).copy()
|
||||
# Merge configurable dicts across configs so that values
|
||||
# bound via `with_config(...)` (e.g. `ls_agent_type`) are
|
||||
# preserved when later configs (e.g. invoke-time) only
|
||||
# specify a subset of keys like `thread_id`.
|
||||
existing = cast(dict, empty.get(k) or {})
|
||||
empty[k] = {**existing, **cast(dict, v)}
|
||||
else:
|
||||
empty[k] = v # type: ignore[literal-required]
|
||||
for k, v in config.items():
|
||||
|
||||
@@ -184,72 +184,39 @@ def add_messages(
|
||||
```
|
||||
|
||||
"""
|
||||
remove_all_idx = None
|
||||
# coerce to list
|
||||
if not isinstance(left, list):
|
||||
left = [left] # type: ignore[assignment]
|
||||
if not isinstance(right, list):
|
||||
right = [right] # type: ignore[assignment]
|
||||
|
||||
# Optimization 1: skip conversion + ID assignment on left when it already
|
||||
# contains fully-resolved BaseMessage objects (the common case after the
|
||||
# first call, since add_messages always returns list[BaseMessage] with IDs).
|
||||
left_msgs: list[BaseMessage]
|
||||
left_seq = cast(list, left)
|
||||
if (
|
||||
left_seq
|
||||
and isinstance(left_seq[0], BaseMessage)
|
||||
and left_seq[0].id is not None
|
||||
and not isinstance(left_seq[0], BaseMessageChunk)
|
||||
):
|
||||
left_msgs = left_seq
|
||||
else:
|
||||
left_msgs = [
|
||||
message_chunk_to_message(cast(BaseMessageChunk, m))
|
||||
for m in convert_to_messages(left)
|
||||
]
|
||||
for m in left_msgs:
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
|
||||
# always normalise right — it's fresh external input
|
||||
right_msgs: list[BaseMessage] = [
|
||||
# coerce to message
|
||||
left = [
|
||||
message_chunk_to_message(cast(BaseMessageChunk, m))
|
||||
for m in convert_to_messages(left)
|
||||
]
|
||||
right = [
|
||||
message_chunk_to_message(cast(BaseMessageChunk, m))
|
||||
for m in convert_to_messages(right)
|
||||
]
|
||||
remove_all_idx = None
|
||||
has_remove = False
|
||||
for idx, m in enumerate(right_msgs):
|
||||
# assign missing ids
|
||||
for m in left:
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
if isinstance(m, RemoveMessage):
|
||||
has_remove = True
|
||||
if m.id == REMOVE_ALL_MESSAGES:
|
||||
remove_all_idx = idx
|
||||
for idx, m in enumerate(right):
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
if isinstance(m, RemoveMessage) and m.id == REMOVE_ALL_MESSAGES:
|
||||
remove_all_idx = idx
|
||||
|
||||
if remove_all_idx is not None:
|
||||
return right_msgs[remove_all_idx + 1 :]
|
||||
return right[remove_all_idx + 1 :]
|
||||
|
||||
# Optimization 2: pure-append fast path — no removals, no ID overlaps with
|
||||
# left, and no duplicate IDs within right (all imply a dedup/update is needed).
|
||||
if not has_remove:
|
||||
left_ids = {m.id for m in left_msgs}
|
||||
right_id_set = {m.id for m in right_msgs}
|
||||
if len(right_id_set) == len(right_msgs) and not (right_id_set & left_ids):
|
||||
result = left_msgs + right_msgs
|
||||
if format == "langchain-openai":
|
||||
return _format_messages(result)
|
||||
elif format:
|
||||
msg = (
|
||||
f"Unrecognized {format=}. Expected one of 'langchain-openai', None."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return result
|
||||
|
||||
# slow path: updates or removals present — full indexed merge
|
||||
merged = left_msgs.copy()
|
||||
# merge
|
||||
merged = left.copy()
|
||||
merged_by_id = {m.id: i for i, m in enumerate(merged)}
|
||||
ids_to_remove = set()
|
||||
for m in right_msgs:
|
||||
for m in right:
|
||||
if (existing_idx := merged_by_id.get(m.id)) is not None:
|
||||
if isinstance(m, RemoveMessage):
|
||||
ids_to_remove.add(m.id)
|
||||
@@ -261,6 +228,7 @@ def add_messages(
|
||||
raise ValueError(
|
||||
f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
|
||||
)
|
||||
|
||||
merged_by_id[m.id] = len(merged)
|
||||
merged.append(m)
|
||||
merged = [m for m in merged if m.id not in ids_to_remove]
|
||||
@@ -270,6 +238,8 @@ def add_messages(
|
||||
elif format:
|
||||
msg = f"Unrecognized {format=}. Expected one of 'langchain-openai', None."
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
pass
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
@@ -831,18 +831,8 @@ class PregelLoop:
|
||||
# parent. For forks (source=update/fork), use the fork's parent
|
||||
# checkpoint ID since the fork was created after the subgraph's
|
||||
# checkpoints from the original execution.
|
||||
#
|
||||
# Only gate on is_time_traveling (not is_replaying). When the
|
||||
# client resumes with an explicit checkpoint_id that happens to
|
||||
# point at the current head (e.g. LangGraph Studio sending
|
||||
# `checkpoint: {checkpoint_id}` alongside Command(resume=...)),
|
||||
# is_replaying is True but is_time_traveling is False. In that
|
||||
# case subgraphs should load their latest checkpoint normally,
|
||||
# not go through ReplayState's before-bound lookup which would
|
||||
# miss subgraph checkpoints created during processing of the
|
||||
# current parent step.
|
||||
replay_state: ReplayState | None = None
|
||||
if is_time_traveling:
|
||||
if self.is_replaying:
|
||||
replay_checkpoint_id = self.checkpoint["id"]
|
||||
if (
|
||||
self.checkpoint_metadata.get("source")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.1.9"
|
||||
version = "1.1.8"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"""Benchmark: add_messages fast-path optimizations.
|
||||
|
||||
Both implementations are inlined so the benchmark is self-contained and
|
||||
immune to import-cache or installed-vs-local confusion.
|
||||
|
||||
Run directly:
|
||||
python tests/test_add_messages_benchmark.py
|
||||
|
||||
Or via pytest (correctness only, numbers printed to stdout):
|
||||
pytest tests/test_add_messages_benchmark.py -s -v
|
||||
"""
|
||||
|
||||
import statistics
|
||||
import time
|
||||
import tracemalloc
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
BaseMessageChunk,
|
||||
HumanMessage,
|
||||
RemoveMessage,
|
||||
convert_to_messages,
|
||||
message_chunk_to_message,
|
||||
)
|
||||
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||
|
||||
# ── original implementation (pre-optimisation) ────────────────────────────────
|
||||
|
||||
|
||||
def _add_messages_original(left, right):
|
||||
remove_all_idx = None
|
||||
if not isinstance(left, list):
|
||||
left = [left]
|
||||
if not isinstance(right, list):
|
||||
right = [right]
|
||||
left = [
|
||||
message_chunk_to_message(cast(BaseMessageChunk, m))
|
||||
for m in convert_to_messages(left)
|
||||
]
|
||||
right = [
|
||||
message_chunk_to_message(cast(BaseMessageChunk, m))
|
||||
for m in convert_to_messages(right)
|
||||
]
|
||||
for m in left:
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
for idx, m in enumerate(right):
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
if isinstance(m, RemoveMessage) and m.id == REMOVE_ALL_MESSAGES:
|
||||
remove_all_idx = idx
|
||||
if remove_all_idx is not None:
|
||||
return right[remove_all_idx + 1 :]
|
||||
merged = left.copy()
|
||||
merged_by_id = {m.id: i for i, m in enumerate(merged)}
|
||||
ids_to_remove = set()
|
||||
for m in right:
|
||||
if (existing_idx := merged_by_id.get(m.id)) is not None:
|
||||
if isinstance(m, RemoveMessage):
|
||||
ids_to_remove.add(m.id)
|
||||
else:
|
||||
ids_to_remove.discard(m.id)
|
||||
merged[existing_idx] = m
|
||||
else:
|
||||
if isinstance(m, RemoveMessage):
|
||||
raise ValueError(
|
||||
f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
|
||||
)
|
||||
merged_by_id[m.id] = len(merged)
|
||||
merged.append(m)
|
||||
return [m for m in merged if m.id not in ids_to_remove]
|
||||
|
||||
|
||||
# ── optimised implementation ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _add_messages_optimized(left, right):
|
||||
if not isinstance(left, list):
|
||||
left = [left]
|
||||
if not isinstance(right, list):
|
||||
right = [right]
|
||||
|
||||
# Optimisation 1: skip conversion + ID assignment on left when it already
|
||||
# contains fully-resolved BaseMessage objects (the common case after the
|
||||
# first call, since add_messages always returns list[BaseMessage] with IDs).
|
||||
if (
|
||||
left
|
||||
and isinstance(left[0], BaseMessage)
|
||||
and not isinstance(left[0], BaseMessageChunk)
|
||||
):
|
||||
left = cast(list[BaseMessage], left)
|
||||
else:
|
||||
left = [
|
||||
message_chunk_to_message(cast(BaseMessageChunk, m))
|
||||
for m in convert_to_messages(left)
|
||||
]
|
||||
for m in left:
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
|
||||
# always normalise right — it's fresh external input
|
||||
right = [
|
||||
message_chunk_to_message(cast(BaseMessageChunk, m))
|
||||
for m in convert_to_messages(right)
|
||||
]
|
||||
remove_all_idx = None
|
||||
has_remove = False
|
||||
for idx, m in enumerate(right):
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
if isinstance(m, RemoveMessage):
|
||||
has_remove = True
|
||||
if m.id == REMOVE_ALL_MESSAGES:
|
||||
remove_all_idx = idx
|
||||
|
||||
if remove_all_idx is not None:
|
||||
return right[remove_all_idx + 1 :]
|
||||
|
||||
# Optimisation 2: pure-append fast path — no removals and no ID overlaps.
|
||||
# Builds one set over left instead of copying left + building a full dict.
|
||||
if not has_remove:
|
||||
left_ids = {m.id for m in left}
|
||||
if not any(m.id in left_ids for m in right):
|
||||
return left + right
|
||||
|
||||
# slow path: updates or removals present — full indexed merge
|
||||
merged = left.copy()
|
||||
merged_by_id = {m.id: i for i, m in enumerate(merged)}
|
||||
ids_to_remove = set()
|
||||
for m in right:
|
||||
if (existing_idx := merged_by_id.get(m.id)) is not None:
|
||||
if isinstance(m, RemoveMessage):
|
||||
ids_to_remove.add(m.id)
|
||||
else:
|
||||
ids_to_remove.discard(m.id)
|
||||
merged[existing_idx] = m
|
||||
else:
|
||||
if isinstance(m, RemoveMessage):
|
||||
raise ValueError(
|
||||
f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
|
||||
)
|
||||
merged_by_id[m.id] = len(merged)
|
||||
merged.append(m)
|
||||
return [m for m in merged if m.id not in ids_to_remove]
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_messages(n: int) -> list[BaseMessage]:
|
||||
return [
|
||||
(HumanMessage if i % 2 == 0 else AIMessage)(
|
||||
content=f"message {i}", id=str(uuid.uuid4())
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _bench_time(fn, left, right, *, iters: int = 2_000) -> float:
|
||||
"""Return median latency in microseconds."""
|
||||
for _ in range(100):
|
||||
fn(list(left), list(right))
|
||||
times = []
|
||||
for _ in range(iters):
|
||||
left_copy, right_copy = list(left), list(right)
|
||||
t0 = time.perf_counter()
|
||||
fn(left_copy, right_copy)
|
||||
times.append(time.perf_counter() - t0)
|
||||
return statistics.median(times) * 1e6
|
||||
|
||||
|
||||
def _bench_memory(fn, left, right) -> int:
|
||||
"""Return peak memory allocated during a single call (bytes)."""
|
||||
# one warm-up so any lazy init is excluded
|
||||
fn(list(left), list(right))
|
||||
left_copy, right_copy = list(left), list(right)
|
||||
tracemalloc.start()
|
||||
tracemalloc.clear_traces()
|
||||
fn(left_copy, right_copy)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
tracemalloc.stop()
|
||||
return peak
|
||||
|
||||
|
||||
# ── scenarios ─────────────────────────────────────────────────────────────────
|
||||
|
||||
SCENARIOS = [
|
||||
("pure append 1 → 1 msg", 1, 1, "append"),
|
||||
("pure append 10 → 1 msg", 10, 1, "append"),
|
||||
("pure append 100 → 1 msg", 100, 1, "append"),
|
||||
("pure append 1000 → 1 msg", 1000, 1, "append"),
|
||||
("pure append 1000 → 5 msgs", 1000, 5, "append"),
|
||||
("update existing 100 → 1 msg", 100, 1, "update"),
|
||||
("remove message 100 → 1 msg", 100, 1, "remove"),
|
||||
]
|
||||
|
||||
|
||||
def _make_inputs(n_left, n_right, mode):
|
||||
left = _make_messages(n_left)
|
||||
right = _make_messages(n_right)
|
||||
if mode == "update":
|
||||
right[0] = AIMessage(content="updated", id=left[0].id)
|
||||
elif mode == "remove":
|
||||
right = [RemoveMessage(id=left[0].id)]
|
||||
return left, right
|
||||
|
||||
|
||||
# ── main output ───────────────────────────────────────────────────────────────
|
||||
|
||||
COL = 36
|
||||
|
||||
|
||||
def run_benchmarks() -> None:
|
||||
print()
|
||||
print("=" * 88)
|
||||
print("add_messages benchmark — time (µs, median of 2 000 iterations)")
|
||||
print("=" * 88)
|
||||
print(f"{'Scenario':<{COL}} {'Original':>10} {'Optimized':>11} {'Speedup':>8}")
|
||||
print("-" * 88)
|
||||
|
||||
for label, n_left, n_right, mode in SCENARIOS:
|
||||
left, right = _make_inputs(n_left, n_right, mode)
|
||||
t_orig = _bench_time(_add_messages_original, left, right)
|
||||
t_opt = _bench_time(_add_messages_optimized, left, right)
|
||||
print(f"{label:<{COL}} {t_orig:>10.2f} {t_opt:>11.2f} {t_orig / t_opt:>7.2f}x")
|
||||
|
||||
print()
|
||||
print("=" * 88)
|
||||
print("add_messages benchmark — peak memory allocated per call (bytes)")
|
||||
print("=" * 88)
|
||||
print(f"{'Scenario':<{COL}} {'Original':>10} {'Optimized':>11} {'Reduction':>10}")
|
||||
print("-" * 88)
|
||||
|
||||
for label, n_left, n_right, mode in SCENARIOS:
|
||||
left, right = _make_inputs(n_left, n_right, mode)
|
||||
m_orig = _bench_memory(_add_messages_original, left, right)
|
||||
m_opt = _bench_memory(_add_messages_optimized, left, right)
|
||||
reduction = (1 - m_opt / m_orig) * 100 if m_orig else 0.0
|
||||
print(f"{label:<{COL}} {m_orig:>10,} {m_opt:>11,} {reduction:>9.1f}%")
|
||||
|
||||
print()
|
||||
print("=" * 88)
|
||||
print("Simulated long thread — 200 steps × 2 msgs appended per step")
|
||||
print("=" * 88)
|
||||
for name, fn in [
|
||||
("original", _add_messages_original),
|
||||
("optimized", _add_messages_optimized),
|
||||
]:
|
||||
state: list = []
|
||||
t0 = time.perf_counter()
|
||||
for step in range(200):
|
||||
new_msgs = [
|
||||
HumanMessage(content=f"step {step} human", id=str(uuid.uuid4())),
|
||||
AIMessage(content=f"step {step} ai", id=str(uuid.uuid4())),
|
||||
]
|
||||
state = fn(state, new_msgs)
|
||||
elapsed = (time.perf_counter() - t0) * 1_000
|
||||
print(f" {name:<12} {elapsed:.2f} ms ({len(state)} messages)")
|
||||
print()
|
||||
|
||||
|
||||
# ── pytest entry-points ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_add_messages_correctness():
|
||||
"""Optimised implementation must match original output for every scenario."""
|
||||
for label, n_left, n_right, mode in SCENARIOS:
|
||||
left, right = _make_inputs(n_left, n_right, mode)
|
||||
expected = _add_messages_original(list(left), list(right))
|
||||
actual = _add_messages_optimized(list(left), list(right))
|
||||
assert len(actual) == len(expected), f"[{label}] length mismatch"
|
||||
for a, b in zip(actual, expected):
|
||||
assert type(a) is type(b), f"[{label}] type mismatch"
|
||||
assert a.id == b.id, f"[{label}] id mismatch"
|
||||
assert a.content == b.content, f"[{label}] content mismatch"
|
||||
|
||||
|
||||
def test_add_messages_benchmark(capsys):
|
||||
run_benchmarks()
|
||||
out = capsys.readouterr().out
|
||||
assert "Speedup" in out
|
||||
assert "Optimized" in out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_benchmarks()
|
||||
@@ -5,7 +5,6 @@ import langchain_core
|
||||
import pytest
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
AIMessageChunk,
|
||||
AnyMessage,
|
||||
HumanMessage,
|
||||
RemoveMessage,
|
||||
@@ -339,123 +338,6 @@ def test_remove_all_messages():
|
||||
]
|
||||
|
||||
|
||||
def test_fast_path_preserves_format_openai():
|
||||
"""Pure-append fast path must still apply the `langchain-openai` formatter."""
|
||||
left = [HumanMessage(content="prior", id="1")]
|
||||
right = [
|
||||
AIMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "foo",
|
||||
"input": {"bar": "baz"},
|
||||
"id": "t1",
|
||||
}
|
||||
],
|
||||
id="2",
|
||||
)
|
||||
]
|
||||
result = add_messages(left, right, format="langchain-openai")
|
||||
assert isinstance(result[0], HumanMessage)
|
||||
assert result[0].content == "prior"
|
||||
assert isinstance(result[1], AIMessage)
|
||||
# formatter collapses the tool_use content block into `tool_calls`
|
||||
assert result[1].content == ""
|
||||
assert len(result[1].tool_calls) == 1
|
||||
assert result[1].tool_calls[0]["name"] == "foo"
|
||||
assert result[1].tool_calls[0]["args"] == {"bar": "baz"}
|
||||
assert result[1].tool_calls[0]["id"] == "t1"
|
||||
|
||||
|
||||
def test_fast_path_rejects_invalid_format():
|
||||
"""Pure-append fast path must validate the `format` arg like the slow path."""
|
||||
left = [HumanMessage(content="prior", id="1")]
|
||||
right = [AIMessage(content="new", id="2")]
|
||||
with pytest.raises(ValueError, match="Unrecognized format="):
|
||||
add_messages(left, right, format="bogus") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_left_starting_with_chunk_is_normalized():
|
||||
"""Opt-1 guard: a `BaseMessageChunk` at left[0] must trigger full conversion."""
|
||||
chunk = AIMessageChunk(content="chunk", id="c1")
|
||||
result = add_messages([chunk], [HumanMessage(content="h", id="h1")])
|
||||
assert len(result) == 2
|
||||
# chunk must be converted to a non-chunk message
|
||||
assert type(result[0]).__name__ == "AIMessage"
|
||||
assert result[0].id == "c1"
|
||||
assert result[1].id == "h1"
|
||||
|
||||
|
||||
def test_left_as_dicts_is_normalized():
|
||||
"""Opt-1 guard: dicts at left[0] must trigger full conversion."""
|
||||
left = [{"role": "user", "content": "hi", "id": "d1"}]
|
||||
right = [AIMessage(content="reply", id="a1")]
|
||||
result = add_messages(left, right)
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[0], HumanMessage)
|
||||
assert result[0].id == "d1"
|
||||
assert result[0].content == "hi"
|
||||
|
||||
|
||||
def test_left_as_tuples_is_normalized():
|
||||
"""Opt-1 guard: tuple-form messages must trigger full conversion."""
|
||||
left = [("user", "hi")]
|
||||
right = [AIMessage(content="reply", id="a1")]
|
||||
result = add_messages(left, right)
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[0], HumanMessage)
|
||||
# id is auto-assigned
|
||||
assert isinstance(result[0].id, str) and UUID(result[0].id, version=4)
|
||||
|
||||
|
||||
def test_left_first_msg_missing_id_is_normalized():
|
||||
"""Opt-1 guard: a BaseMessage without an id at left[0] falls to the else branch."""
|
||||
left = [HumanMessage(content="hi")] # no id
|
||||
right = [AIMessage(content="reply", id="a1")]
|
||||
result = add_messages(left, right)
|
||||
assert len(result) == 2
|
||||
# left's id must have been auto-assigned
|
||||
assert isinstance(result[0].id, str) and UUID(result[0].id, version=4)
|
||||
|
||||
|
||||
def test_duplicate_ids_in_right_with_nonempty_left():
|
||||
"""Opt-2 guard: intra-right duplicate ids must take slow path (dedup kept)."""
|
||||
left = [HumanMessage(content="prior", id="1")]
|
||||
right = [
|
||||
AIMessage(content="first", id="2"),
|
||||
AIMessage(content="second", id="2"),
|
||||
]
|
||||
result = add_messages(left, right)
|
||||
assert len(result) == 2
|
||||
assert result[0].id == "1"
|
||||
assert result[1].id == "2"
|
||||
assert result[1].content == "second"
|
||||
|
||||
|
||||
def test_right_with_none_ids_pure_append():
|
||||
"""Fast path still correct when right entries start with id=None (fresh uuids assigned)."""
|
||||
left = [HumanMessage(content="prior", id="1")]
|
||||
right = [AIMessage(content="a"), AIMessage(content="b")]
|
||||
result = add_messages(left, right)
|
||||
assert len(result) == 3
|
||||
assert result[0].id == "1"
|
||||
for m in result[1:]:
|
||||
assert isinstance(m.id, str) and UUID(m.id, version=4)
|
||||
# fresh uuids must be distinct
|
||||
assert result[1].id != result[2].id
|
||||
|
||||
|
||||
def test_fast_path_returns_fresh_list():
|
||||
"""Fast path must return a new list object (not mutate or alias left)."""
|
||||
left = [HumanMessage(content="prior", id="1")]
|
||||
right = [AIMessage(content="new", id="2")]
|
||||
result = add_messages(left, right)
|
||||
assert result is not left
|
||||
# left must be untouched
|
||||
assert len(left) == 1
|
||||
assert left[0].id == "1"
|
||||
|
||||
|
||||
def test_push_messages_in_graph():
|
||||
class MessagesState(TypedDict):
|
||||
messages: Annotated[list[AnyMessage], add_messages]
|
||||
|
||||
@@ -1113,70 +1113,6 @@ def test_subgraph_interrupt_replay_from_parent_then_resume(
|
||||
]
|
||||
|
||||
|
||||
def test_subgraph_interrupt_resume_with_explicit_head_checkpoint_id(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Resume with Command(resume=...) plus the current head checkpoint_id
|
||||
in config. The subgraph must continue from the interrupted node, not
|
||||
restart from scratch. Explicit checkpoint_id triggers is_replaying but
|
||||
this is a resume, not a time-travel, so ReplayState should not apply."""
|
||||
|
||||
called: list[str] = []
|
||||
|
||||
def step_a(state: State) -> State:
|
||||
called.append("step_a")
|
||||
return {"value": ["sub_a"]}
|
||||
|
||||
def ask_human(state: State) -> State:
|
||||
called.append("ask_human")
|
||||
answer = interrupt("Provide input:")
|
||||
return {"value": [f"human:{answer}"]}
|
||||
|
||||
def step_b(state: State) -> State:
|
||||
called.append("step_b")
|
||||
return {"value": ["sub_b"]}
|
||||
|
||||
subgraph = (
|
||||
StateGraph(State)
|
||||
.add_node("step_a", step_a)
|
||||
.add_node("ask_human", ask_human)
|
||||
.add_node("step_b", step_b)
|
||||
.add_edge(START, "step_a")
|
||||
.add_edge("step_a", "ask_human")
|
||||
.add_edge("ask_human", "step_b")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("subgraph_node", subgraph)
|
||||
.add_edge(START, "subgraph_node")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# Run until interrupt fires in subgraph
|
||||
graph.invoke({"value": []}, config)
|
||||
assert called == ["step_a", "ask_human"]
|
||||
|
||||
# Resume with explicit head checkpoint_id in config
|
||||
head_checkpoint_id = graph.get_state(config).config["configurable"]["checkpoint_id"]
|
||||
called.clear()
|
||||
resume_config = {
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_id": head_checkpoint_id,
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
result = graph.invoke(Command(resume="answer"), resume_config)
|
||||
|
||||
assert called == ["ask_human", "step_b"]
|
||||
assert "__interrupt__" not in result
|
||||
assert result["value"] == ["sub_a", "human:answer", "sub_b"]
|
||||
|
||||
|
||||
def test_subgraph_replay_loads_accumulated_state_then_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
|
||||
@@ -427,3 +427,32 @@ def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None:
|
||||
"thread_id": "th-123",
|
||||
"user_id": "uid-1",
|
||||
}
|
||||
|
||||
|
||||
def test_ensure_config_merges_configurable_across_configs() -> None:
|
||||
"""`ensure_config(bound, invoke_time)` should merge `configurable` dicts.
|
||||
|
||||
Prior to the fix, a later config's `configurable` dict fully overwrote an
|
||||
earlier one, causing values bound via `with_config({"configurable": {...}})`
|
||||
(e.g. `ls_agent_type="root"` set by `create_agent`) to be dropped whenever
|
||||
an invoke-time config supplied any other configurable key like `thread_id`.
|
||||
"""
|
||||
bound: RunnableConfig = {
|
||||
"configurable": {"ls_agent_type": "root", "custom_setting": "keep_me"},
|
||||
"metadata": {"ls_integration": "langchain_create_agent"},
|
||||
}
|
||||
invoke_time: RunnableConfig = {
|
||||
"configurable": {"thread_id": "t-1"},
|
||||
}
|
||||
merged = ensure_config(bound, invoke_time)
|
||||
# Both the bound and invoke-time configurable keys are preserved.
|
||||
assert merged["configurable"] == {
|
||||
"ls_agent_type": "root",
|
||||
"custom_setting": "keep_me",
|
||||
"thread_id": "t-1",
|
||||
}
|
||||
# Invoke-time values still override bound values when they collide.
|
||||
override: RunnableConfig = {"configurable": {"ls_agent_type": "subagent"}}
|
||||
merged2 = ensure_config(bound, override)
|
||||
assert merged2["configurable"]["ls_agent_type"] == "subagent"
|
||||
assert merged2["configurable"]["custom_setting"] == "keep_me"
|
||||
|
||||
Generated
+1
-1
@@ -1367,7 +1367,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.9"
|
||||
version = "1.1.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Generated
+1
-1
@@ -268,7 +268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.9"
|
||||
version = "1.1.8"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Generated
+1
-1
@@ -281,7 +281,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.9"
|
||||
version = "1.1.8"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Reference in New Issue
Block a user