From 3ce6523fafbf06ed6e66a1386f95dad82a8dda28 Mon Sep 17 00:00:00 2001 From: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Date: Sun, 31 May 2026 22:12:13 +0100 Subject: [PATCH] fix: update deepagents and langchain versions (#251) * fix: update deepagents and langchain versions; enhance _reduce_messages_delta handling for None state * fix: update langchain version constraint to >=1.3 in pyproject.toml and uv.lock --- EvoScientist/sessions.py | 12 ++-- pyproject.toml | 4 +- tests/test_sessions.py | 129 ++++++++++++++++++++++++++++++++++++++- uv.lock | 22 +++---- 4 files changed, 149 insertions(+), 18 deletions(-) diff --git a/EvoScientist/sessions.py b/EvoScientist/sessions.py index 6259d36..d26af05 100644 --- a/EvoScientist/sessions.py +++ b/EvoScientist/sessions.py @@ -24,7 +24,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, cast import aiosqlite from langchain_core.messages import ( @@ -509,7 +509,7 @@ async def _table_exists(conn: aiosqlite.Connection, table: str) -> bool: def _reduce_messages_delta( - state: list[AnyMessage], writes: list[Any] + state: list[AnyMessage] | None, writes: list[Any] ) -> list[AnyMessage]: """Inline copy of deepagents' ``_messages_delta_reducer``. @@ -532,6 +532,10 @@ def _reduce_messages_delta( so HTTP-driven graphs (and persisted blobs that round-tripped through JSON) reconstruct correctly without a separate coercion step. + + ``state`` may be None on ``DeltaChannel.replay_writes`` for threads + whose earliest checkpoint did not seed ``messages: []``, and is + treated as the empty list. """ flat: list[Any] = [] for w in writes: @@ -544,9 +548,9 @@ def _reduce_messages_delta( state_msgs: list[AnyMessage] = ( state if state and isinstance(state[0], BaseMessage) - else convert_to_messages(state) # type: ignore[arg-type] + else cast("list[AnyMessage]", convert_to_messages(state or [])) ) - msgs: list[AnyMessage] = convert_to_messages(flat) # type: ignore[assignment] + msgs: list[AnyMessage] = cast("list[AnyMessage]", convert_to_messages(flat)) # ``REMOVE_ALL_MESSAGES`` resets everything; honor the last sentinel # in the batch — discard prior state plus every write before it. diff --git a/pyproject.toml b/pyproject.toml index e9b760c..917f79c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,8 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "deepagents[quickjs]~=0.6.2", - "langchain>=1.2", + "deepagents[quickjs]~=0.6.7", + "langchain>=1.3", "langchain-anthropic>=1.4", "langchain-openai>=1.2", "langchain-nvidia-ai-endpoints>=1.2", diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 54a8782..44e8119 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -8,12 +8,15 @@ import unittest from datetime import UTC from unittest.mock import patch -from langchain_core.messages import AIMessage, HumanMessage +import pytest +from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from langgraph.graph.message import REMOVE_ALL_MESSAGES from EvoScientist.sessions import ( AGENT_NAME, _format_relative_time, + _reduce_messages_delta, delete_thread, find_similar_threads, generate_thread_id, @@ -1868,5 +1871,129 @@ class TestDbStats(unittest.TestCase): assert stats["size_bytes"] == 0 +class TestReduceMessagesDeltaNoneState(unittest.TestCase): + """Direct unit tests for the inline ``_reduce_messages_delta`` reducer. + + Regression for the None-state crash: ``DeltaChannel.replay_writes`` + can hand the reducer ``state=None`` for threads whose earliest + checkpoint never seeded ``messages: []``. Before the fix, the slow + path passed ``None`` straight into ``convert_to_messages`` and raised; + now ``state or []`` is substituted. + """ + + def test_none_state_simple_append(self): + result = _reduce_messages_delta(None, [[HumanMessage(content="hi", id="1")]]) + assert len(result) == 1 + assert result[0].content == "hi" + assert result[0].id == "1" + + def test_none_state_empty_writes(self): + # No state and nothing to append → empty list, no crash. + assert _reduce_messages_delta(None, []) == [] + + def test_empty_state_still_appends(self): + # Regression guard: an explicit empty-list state must behave the + # same as None — append the single write. + result = _reduce_messages_delta([], [[AIMessage(content="yo", id="2")]]) + assert len(result) == 1 + assert result[0].content == "yo" + assert result[0].id == "2" + + +def _signature(messages): + """Comparable shape for reducer-output equality assertions.""" + return [(type(m).__name__, m.id, m.content) for m in messages] + + +def _import_upstream_reducer(): + """Import deepagents' private delta reducer, or fail loudly. + + A ``pytest.fail`` (not ``skip``) is deliberate: this test is the + tripwire that fires when the upstream private symbol is renamed or + relocated. A silent skip would let semantic drift between EvoSci's + inline copy (``sessions.py``) and upstream go unnoticed. + """ + try: + from deepagents._messages_reducer import ( + _messages_delta_reducer as upstream, + ) + except ImportError as exc: # pragma: no cover - tripwire path + pytest.fail( + "deepagents._messages_reducer._messages_delta_reducer could not " + f"be imported ({exc}). The upstream private reducer that EvoSci's " + "inline copy in sessions.py (_reduce_messages_delta) mirrors has " + "moved or been renamed. Re-locate the upstream symbol and " + "re-evaluate the inline copy for semantic drift before adjusting " + "this test." + ) + return upstream + + +class TestReduceMessagesDeltaUpstreamParity: + """Behavioral parity vs deepagents' private ``_messages_delta_reducer``. + + Drift detector: if upstream changes the reducer's semantics (dedup, + tombstone, reset, coercion) the EvoSci inline copy must be updated to + match. These cases pass equivalent batched writes to both functions + and assert identical output. (EvoSci's signature is ``writes: list[Any]`` + and upstream's is ``list[list[AnyMessage]]``, but both flatten lists + vs single items the same way, so batched-list writes are equivalent.) + """ + + @pytest.fixture(scope="class") + def upstream(self): + return _import_upstream_reducer() + + def _assert_parity(self, upstream, state, writes): + evo_out = _reduce_messages_delta(state, writes) + up_out = upstream(state, writes) + assert _signature(evo_out) == _signature(up_out) + return evo_out + + def test_parity_none_state_append(self, upstream): + out = self._assert_parity( + upstream, None, [[HumanMessage(content="hi", id="a1")]] + ) + assert _signature(out) == [("HumanMessage", "a1", "hi")] + + def test_parity_dedup_by_id(self, upstream): + state = [HumanMessage(content="orig", id="1")] + writes = [[HumanMessage(content="updated", id="1")]] + out = self._assert_parity(upstream, state, writes) + # In-place update, no duplicate appended. + assert _signature(out) == [("HumanMessage", "1", "updated")] + + def test_parity_remove_message_tombstone(self, upstream): + state = [ + HumanMessage(content="keep", id="1"), + AIMessage(content="drop", id="2"), + ] + writes = [[RemoveMessage(id="2")]] + out = self._assert_parity(upstream, state, writes) + assert _signature(out) == [("HumanMessage", "1", "keep")] + + def test_parity_remove_all_then_append(self, upstream): + state = [ + HumanMessage(content="old1", id="1"), + AIMessage(content="old2", id="2"), + ] + writes = [ + [ + RemoveMessage(id=REMOVE_ALL_MESSAGES), + HumanMessage(content="fresh", id="3"), + ] + ] + out = self._assert_parity(upstream, state, writes) + # Sentinel wipes prior state + earlier writes; only "fresh" remains. + assert _signature(out) == [("HumanMessage", "3", "fresh")] + + def test_parity_dict_shorthand_coercion(self, upstream): + # Raw dict shorthand must coerce to a typed BaseMessage identically + # in both reducers. + writes = [[{"role": "user", "content": "x", "id": "d1"}]] + out = self._assert_parity(upstream, None, writes) + assert _signature(out) == [("HumanMessage", "d1", "x")] + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 5fbbfcc..d332ef4 100644 --- a/uv.lock +++ b/uv.lock @@ -794,7 +794,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.6.2" +version = "0.6.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -804,9 +804,9 @@ dependencies = [ { name = "langsmith" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/3f/eade375f843b05abdae6c667f341300f13b810b3d69e7f1ef9a093d17bae/deepagents-0.6.2.tar.gz", hash = "sha256:81bc6f69c6f54bd0b54ce929e8299003a604747404e7f9d208a66842c5fbf1f3", size = 179651, upload-time = "2026-05-18T23:57:50.072Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/bb/bb837a2c51631fe9d7eedf6aca7629ddca6336831801e75efcd2f5fa9c27/deepagents-0.6.7.tar.gz", hash = "sha256:af7b5857b28e29a847a4ced4cc7aaa809d33d42107696b1ca5d978d17e96b831", size = 194236, upload-time = "2026-05-30T04:42:14.591Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/cf/a760686e1bb347287bcb9f741134a088980112a7633d3b2017fb13a8adb0/deepagents-0.6.2-py3-none-any.whl", hash = "sha256:202c42c06b5a7abb762dedba6a57073253096fa28effa6e156db280e10027322", size = 204754, upload-time = "2026-05-18T23:57:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/97/25/3a7f23d04778ccb95542f1b1ed39826290916221cc8203d0fb8b26c3edc1/deepagents-0.6.7-py3-none-any.whl", hash = "sha256:3518c1e5f4b9f6588ba39912b668b42b5c864b99a638e94c166d1c7176c7388e", size = 218740, upload-time = "2026-05-30T04:42:13.225Z" }, ] [package.optional-dependencies] @@ -981,13 +981,13 @@ requires-dist = [ { name = "certifi", marker = "extra == 'wechat'", specifier = ">=2024.0" }, { name = "cryptography", marker = "extra == 'all-channels'", specifier = ">=41.0" }, { name = "cryptography", marker = "extra == 'qq'", specifier = ">=41.0" }, - { name = "deepagents", extras = ["quickjs"], specifier = "~=0.6.2" }, + { name = "deepagents", extras = ["quickjs"], specifier = "~=0.6.7" }, { name = "discord-py", marker = "extra == 'all-channels'", specifier = ">=2.3" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" }, { name = "faster-whisper", marker = "extra == 'stt'", specifier = ">=1.0" }, { name = "filelock", specifier = ">=3.16" }, { name = "httpx", specifier = ">=0.28" }, - { name = "langchain", specifier = ">=1.2" }, + { name = "langchain", specifier = ">=1.3" }, { name = "langchain-anthropic", specifier = ">=1.4" }, { name = "langchain-google-genai", specifier = ">=4.2" }, { name = "langchain-mcp-adapters", specifier = ">=0.2" }, @@ -1944,16 +1944,16 @@ wheels = [ [[package]] name = "langchain" -version = "1.3.1" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/e5/6350e77a9e2764eaafcb2d581cbf0b800f53c6bc98fdf5ebc85f3a931ded/langchain-1.3.1.tar.gz", hash = "sha256:bc283c220233230f48b8e50ab1fbf1b688bcb206d933fa448d40a9b143177f62", size = 581329, upload-time = "2026-05-15T18:14:55.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/d0/c7f9d3d26c0e3f8bb146c6d707ee0fc1d30d8da65a59626e8a580085e929/langchain-1.3.2.tar.gz", hash = "sha256:ffd5f204a46b5fa1a38bf89ba3b45ca0902c02d18fa7d2a2eaeaeb1f5bf19d0a", size = 600598, upload-time = "2026-05-26T18:17:57.715Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/11/3d7ed10b535413a07ed5e15682abcb77f3c4204ac49586977a495f9b24e6/langchain-1.3.1-py3-none-any.whl", hash = "sha256:154e9c30c90b391eba4315296f6bf6b6fac6b058ddea4cc771a10470968fe36f", size = 114345, upload-time = "2026-05-15T18:14:53.984Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/a54edcd1c48163de5642eb10fa2cb58b13a8889c659964f63f0306b58b1e/langchain-1.3.2-py3-none-any.whl", hash = "sha256:900f6b3f4ee08b9ba3cdbe667dbf42525bd6f66a4a07a7f1db26262673e41ed6", size = 121225, upload-time = "2026-05-26T18:17:56.075Z" }, ] [[package]] @@ -2104,7 +2104,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.2.0" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -2114,9 +2114,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/61/d5d25e783035aa307d289b37e082258a6061c0fb4caa4a284f3bf1e87169/langgraph-1.2.0.tar.gz", hash = "sha256:4a9baaf62afc5d5f63144a50095140a34b9aa9b7cea695d25326d564775348e7", size = 690248, upload-time = "2026-05-12T03:46:39.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/5a/ffc12434ee8aecab830d58b4d204ddea45073eae7639c963310f671a5bf5/langgraph-1.2.2.tar.gz", hash = "sha256:f54a98458976b3ff0774683867df125fb52d8dbedeb2441d0b0656a51331cee5", size = 695730, upload-time = "2026-05-26T18:07:28.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/e8/e3304ac0015c2bdb04ad9785e4ed65c788855ce7857ce6104dd2f5d322db/langgraph-1.2.0-py3-none-any.whl", hash = "sha256:03fd5895a8d4b70db1ff63ebc3bacead29dd20cd794a8b1a483e7ec9018f7a65", size = 234262, upload-time = "2026-05-12T03:46:37.971Z" }, + { url = "https://files.pythonhosted.org/packages/42/9b/b08d578bba73e25351152dfd3d6d21e81210a5fff1b6f26e56f33197c8f5/langgraph-1.2.2-py3-none-any.whl", hash = "sha256:0a851bf4ba5939c5474a2fd57e6b439b5315283e254e42943bd392c2d71a5e03", size = 236376, upload-time = "2026-05-26T18:07:26.577Z" }, ] [[package]]