fix(memory): preserve summaries when extraction returns invalid types (#8192)

Co-authored-by: xiaoyi <xyi74976-del@users.noreply.github.com>
This commit is contained in:
yi-coding 2026-11-13 14:13:47 +08:00 committed by GitHub
parent 3244d4a8ff
commit 94447d12b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 115 additions and 2 deletions

View File

@ -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:

View File

@ -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

View File

@ -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