diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 8ae937c..5cf7f21 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -750,9 +750,6 @@ async def compact_conversation( Returns a structured ``CompactResult``. """ from langchain_core.messages.utils import count_tokens_approximately - from langchain_core.runnables import RunnableConfig - - config: RunnableConfig = {"configurable": {"thread_id": thread_id}} try: state_values = await graph_gateway.get_state_values(target, thread_id) @@ -856,22 +853,18 @@ async def compact_conversation( # Generate summary (LLM call) summary = await middleware._acreate_summary(to_summarize) - # Inject thread_id into LangGraph contextvar so _get_thread_id() finds it - # (compact runs outside a runnable context, so get_config() would fail - # and the middleware would generate a random "session_xxx" filename instead - # of reusing the real thread_id). - from langgraph.config import var_child_runnable_config - - _token = var_child_runnable_config.set(config) + # Reuse the persisted _summarization_session_id (or generate one) so + # history keeps appending to a single file; re-persisted below. + session_id = middleware._get_session_id(state_values) # Offload old messages to backend file_path: str | None = None try: - file_path = await middleware._aoffload_to_backend(backend, to_summarize) + file_path = await middleware._aoffload_to_backend( + backend, to_summarize, session_id + ) except Exception: pass # non-fatal — proceed without offloaded history - finally: - var_child_runnable_config.reset(_token) from langchain_core.messages import HumanMessage @@ -917,7 +910,7 @@ async def compact_conversation( await graph_gateway.update_state_values( target, thread_id, - {"_summarization_event": new_event}, + {"_summarization_event": new_event, "_summarization_session_id": session_id}, ) return CompactResult( diff --git a/pyproject.toml b/pyproject.toml index cc68161..12a3ed7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "deepagents[quickjs]~=0.7.5", + "deepagents[quickjs]~=0.7.6", "langchain>=1.3", "langchain-anthropic>=1.5", "langchain-openai>=1.2", diff --git a/tests/test_compact_command.py b/tests/test_compact_command.py index 7d8b45a..04eec64 100644 --- a/tests/test_compact_command.py +++ b/tests/test_compact_command.py @@ -240,7 +240,7 @@ class TestCompactSuccess: mock_middleware_inst._partition_messages.return_value = (to_summarize, to_keep) mock_middleware_inst._acreate_summary = AsyncMock(return_value="Summary text") mock_middleware_inst._aoffload_to_backend = AsyncMock( - return_value="/conversation_history/tid.md" + return_value="/conversation_history/session_abc123.md" ) mock_middleware_inst._build_new_messages_with_path.return_value = [summary_msg] mock_middleware_inst._compute_state_cutoff.return_value = 15 @@ -342,6 +342,84 @@ class TestCompactSuccess: assert event_data["_summarization_event"]["file_path"] is None +class TestCompactOffloadWire: + """Offload flow against the REAL SummarizationMiddleware and backend. + + ``_aoffload_to_backend`` is deliberately not mocked: /compact swallows + offload exceptions, so a signature drift in deepagents (0.7.6 added a + required ``session_id``) silently skips offload instead of crashing — + only a wire-level test catches that. + """ + + @staticmethod + def _build_messages(): + from langchain_core.messages import AIMessage, HumanMessage + + msgs = [] + for i in range(4): + msgs.append(HumanMessage(content=f"question {i} " * 50)) + msgs.append(AIMessage(content=f"answer {i} " * 50)) + return msgs + + async def _compact_real(self, graph_gateway, tmp_path): + from deepagents.backends import FilesystemBackend + from langchain_core.language_models import FakeListChatModel + + model = FakeListChatModel(responses=["SUMMARY"]) + backend = FilesystemBackend(root_dir=tmp_path) + + with ( + patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=model), + patch( + "EvoScientist.EvoScientist._get_default_backend", + return_value=backend, + ), + patch( + "deepagents.middleware.summarization.compute_summarization_defaults", + return_value={"keep": ("messages", 2)}, + ), + ): + return await _compact(graph_gateway, input_tokens_hint=150_000) + + async def test_offload_writes_history_and_persists_session_id(self, tmp_path): + graph_gateway = FakeGraphGateway( + state_values={ + "messages": self._build_messages(), + "_summarization_event": None, + } + ) + + result = await self._compact_real(graph_gateway, tmp_path) + + assert result.status == "ok" + history_files = list((tmp_path / "conversation_history").glob("*.md")) + assert len(history_files) == 1, "offload silently skipped — no history file" + assert "question 0" in history_files[0].read_text() + + update = graph_gateway.updated_states[0][2] + session_id = update["_summarization_session_id"] + assert history_files[0].name == f"{session_id}.md" + event = update["_summarization_event"] + assert event["file_path"] == f"/conversation_history/{session_id}.md" + + async def test_offload_reuses_persisted_session_id(self, tmp_path): + graph_gateway = FakeGraphGateway( + state_values={ + "messages": self._build_messages(), + "_summarization_event": None, + "_summarization_session_id": "session_deadbeef", + } + ) + + result = await self._compact_real(graph_gateway, tmp_path) + + assert result.status == "ok" + history_file = tmp_path / "conversation_history" / "session_deadbeef.md" + assert history_file.exists(), "offload did not reuse the persisted session id" + update = graph_gateway.updated_states[0][2] + assert update["_summarization_session_id"] == "session_deadbeef" + + class TestRenderCompactResult: """Test the Rich rendering of CompactResult.""" diff --git a/uv.lock b/uv.lock index 916a0fd..db6128d 100644 --- a/uv.lock +++ b/uv.lock @@ -859,7 +859,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.7.5" +version = "0.7.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -870,9 +870,9 @@ dependencies = [ { name = "packaging" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/2f/7b010aaf7f39e9da9ce8dc574902126220cac6c41205bf122e3a27b7fcec/deepagents-0.7.5.tar.gz", hash = "sha256:ed44e956ed8e4ccd57203b9925c530707cdb815ffb56fe8ceb9b5b63d45a20c1", size = 273937, upload-time = "2026-08-06T14:05:36.317Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/10/ce2fc605f0980c8ae453aa58071b926f63d827a3db832bff017b906c6d20/deepagents-0.7.6.tar.gz", hash = "sha256:972f81f81eca7cd16c0bd7a3f8afdab420eed64331c0ff0e7efbaf860624c7e5", size = 274879, upload-time = "2026-08-13T20:41:15.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/c9/d4778dd09aaeb03b9653bb2be8591f3b7179ec5757a467f46e97627bccca/deepagents-0.7.5-py3-none-any.whl", hash = "sha256:a1be73d150929c5e826d67a05af6277748f25e4b53ae530b3fb1454df9e3ca95", size = 300778, upload-time = "2026-08-06T14:05:35.121Z" }, + { url = "https://files.pythonhosted.org/packages/42/6e/dd0f081742c3e898a83874d0b110f56b0664b6241aed7da7e407f62460d9/deepagents-0.7.6-py3-none-any.whl", hash = "sha256:e85e000475cc67ad6bf1131a536d9a136e6916c807fcd21fd09f145bea09fc98", size = 301185, upload-time = "2026-08-13T20:41:14.205Z" }, ] [package.optional-dependencies] @@ -1061,7 +1061,7 @@ 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.7.5" }, + { name = "deepagents", extras = ["quickjs"], specifier = "~=0.7.6" }, { 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" },