From 94447d12b687357c4d446b46cb9031f17b525865 Mon Sep 17 00:00:00 2001 From: yi-coding Date: Fri, 13 Nov 2026 14:13:47 +0800 Subject: [PATCH] fix(memory): preserve summaries when extraction returns invalid types (#8192) Co-authored-by: xiaoyi --- .../backends/deermem/deermem/core/updater.py | 4 +- .../tests/test_memory_summary_value_types.py | 109 ++++++++++++++++++ docs/guide/memory.md | 4 + 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_memory_summary_value_types.py diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py index 9bd9ac743d..6e9641cced 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py @@ -2206,7 +2206,7 @@ class MemoryUpdater: user_updates = update_data.get("user", {}) for section in ["workContext", "personalContext", "topOfMind", "cognitiveStyle"]: section_data = user_updates.get(section, {}) - if not isinstance(section_data, dict) or not section_data.get("shouldUpdate") or not section_data.get("summary"): + if not isinstance(section_data, dict) or not section_data.get("shouldUpdate") or not isinstance(section_data.get("summary"), str) or not section_data["summary"]: continue rejection_reason = _summary_scope_gate_reason(section_data) if rejection_reason is not None: @@ -2221,7 +2221,7 @@ class MemoryUpdater: history_updates = update_data.get("history", {}) for section in ["recentMonths", "earlierContext", "longTermBackground"]: section_data = history_updates.get(section, {}) - if not isinstance(section_data, dict) or not section_data.get("shouldUpdate") or not section_data.get("summary"): + if not isinstance(section_data, dict) or not section_data.get("shouldUpdate") or not isinstance(section_data.get("summary"), str) or not section_data["summary"]: continue rejection_reason = _summary_scope_gate_reason(section_data) if rejection_reason is not None: diff --git a/backend/tests/test_memory_summary_value_types.py b/backend/tests/test_memory_summary_value_types.py new file mode 100644 index 0000000000..cd665fee12 --- /dev/null +++ b/backend/tests/test_memory_summary_value_types.py @@ -0,0 +1,109 @@ +"""Malformed extractor summaries preserve existing user-global memory.""" + +import json +from pathlib import Path +from typing import Any + +import pytest +from langchain_core.messages import AIMessage, HumanMessage + +from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig +from deerflow.agents.memory.backends.deermem.deermem.core.storage import ( + FileMemoryStorage, +) +from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater + +_SUMMARY_SECTIONS = [ + ("user", "workContext"), + ("user", "personalContext"), + ("user", "topOfMind"), + ("user", "cognitiveStyle"), + ("history", "recentMonths"), + ("history", "earlierContext"), + ("history", "longTermBackground"), +] + + +class _Extractor: + def __init__(self, response: dict[str, Any]): + self.response = response + self.calls = 0 + + def invoke(self, prompt: Any, config: Any = None) -> AIMessage: + self.calls += 1 + return AIMessage(content=json.dumps(self.response)) + + +@pytest.mark.parametrize("group,section", _SUMMARY_SECTIONS) +@pytest.mark.parametrize( + "summary", + [ + pytest.param(["Software engineer"], id="list"), + pytest.param({"occupation": "engineer"}, id="object"), + pytest.param(42, id="integer"), + pytest.param(1.5, id="float"), + pytest.param(True, id="boolean"), + pytest.param(None, id="null"), + pytest.param("", id="empty"), + pytest.param("Updated valid text", id="string"), + ], +) +def test_extracted_summary_value_preserves_memory_and_consumes_batch(tmp_path: Path, group: str, section: str, summary: Any) -> None: + config = DeerMemConfig(storage_path=str(tmp_path), retrieval_adapter="") + storage = FileMemoryStorage(config) + previous = { + "summary": "User is a software engineer", + "updatedAt": "2026-11-01T00:00:00Z", + } + storage.update_summaries({group: {section: previous}}, user_id="alice") + assert storage.reload("researcher", user_id="alice")[group][section] == previous + + # A valid sibling update and fact must still commit beside a bad section. + sibling_group, sibling_section = ("history", "recentMonths") if group == "user" else ("user", "workContext") + response: dict[str, Any] = { + "user": {}, + "history": {}, + "newFacts": [ + { + "content": "User prefers dark mode", + "category": "preference", + "confidence": 0.9, + "scope": "user", + "durability": "durable", + "authority": "descriptive", + } + ], + } + response[group][section] = { + "shouldUpdate": True, + "summary": summary, + "scope": "user", + "authority": "descriptive", + } + response[sibling_group][sibling_section] = { + "shouldUpdate": True, + "summary": "User prefers concise updates", + "scope": "user", + "authority": "descriptive", + } + model = _Extractor(response) + messages = [HumanMessage(content="Please remember that I prefer dark mode", id="message-1")] + updater = MemoryUpdater(config, storage, model) + + assert updater.update_memory(messages, thread_id="thread-1", agent_name="researcher", user_id="alice") is True + + # Reopen real storage so cache state cannot conceal a corrupted manifest. + reopened = FileMemoryStorage(config) + document = reopened.reload("researcher", user_id="alice") + assert [fact["content"] for fact in document["facts"]] == ["User prefers dark mode"] + assert document[sibling_group][sibling_section]["summary"] == "User prefers concise updates" + if isinstance(summary, str) and summary: + assert document[group][section]["summary"] == "Updated valid text" + assert document[group][section]["updatedAt"] != previous["updatedAt"] + else: + assert document[group][section] == previous + + # The persisted watermark also survives a restart, avoiding paid re-extraction. + restarted_updater = MemoryUpdater(config, reopened, model) + assert restarted_updater.update_memory(messages, thread_id="thread-1", agent_name="researcher", user_id="alice") is True + assert model.calls == 1 diff --git a/docs/guide/memory.md b/docs/guide/memory.md index 9f208d669d..25901f7176 100644 --- a/docs/guide/memory.md +++ b/docs/guide/memory.md @@ -5,6 +5,10 @@ counter, including the omission marker and retained facts. Mixed-language text does not rely on an average character/token ratio; the existing guaranteed-fact allowance remains unchanged. +Automatic DeerMem summary updates accept only nonempty strings. An invalid +summary value preserves the existing section and its timestamp while valid facts +and other summaries in the same extraction response can still be saved. + Settings > Appearance > Language also supplies the preferred language for replies and new automatic DeerMem facts and summary updates on subsequent chat turns and replays. Explicit user language requests take precedence. Existing memories are