deer-flow/backend/tests/test_memory_documentation.py
lihongyuan99 07a4770e32
docs(harness): scope the memory guides per user and nest DeerMem knobs (#7463)
* test(memory): pin the memory guides to the layout DeerMem resolves

The en and zh harness guides still document the pre-abstraction storage
shape, and nothing in CI reads prose, so the drift stayed invisible.
Derive the per-user manifest and per-agent facts paths from
memory_file_path()/agent_facts_directory() and take the allowed memory:
top-level keys from the public MemoryConfig model.

* docs(harness): scope memory docs per user and nest DeerMem knobs

Both guides claimed a shared {base_dir}/memory.json plus one memory.json
per agent; DeerMem keeps one manifest per user and puts agent state in a
facts directory beside it. The zh Configuration example also still showed
DeerMem-private keys at the memory top level, which config.example.yaml
and the en twin already place under backend_config.

* docs(memory): align per-agent facts overviews in both languages

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 15:14:29 +08:00

103 lines
4.7 KiB
Python

"""Keep the user-facing memory guides aligned with DeerMem's real storage layout.
The guides once described the pre-abstraction layout (a shared
``{base_dir}/memory.json`` plus one ``agents/{agent_name}/memory.json`` per
agent). DeerMem scopes the memory manifest per user and keeps agent state in a
``facts`` directory beside it, and the host loader moved every DeerMem-private
knob under ``memory.backend_config``. Both guides are prose, so nothing in CI
noticed; this test derives the documented shape from the same functions the
runtime uses.
"""
import re
from pathlib import Path
import pytest
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
from deerflow.agents.memory.backends.deermem.deermem.core.paths import agent_facts_directory, memory_file_path
from deerflow.config.memory_config import MemoryConfig
REPO_ROOT = Path(__file__).resolve().parents[2]
MEMORY_GUIDES = (
Path("frontend/src/content/en/harness/memory.mdx"),
Path("frontend/src/content/zh/harness/memory.mdx"),
)
# A root that never collides with the placeholders the guides spell out.
PROBE_ROOT = "/deer-flow-test-root"
PROBE_USER_ID = "probe-user"
PROBE_AGENT_NAME = "Probe-Agent"
# The pre-abstraction per-agent manifest file. DeerMem writes agent state under
# ``agents/{agent_name}/facts/`` instead, so a guide naming this path is stale.
LEGACY_PER_AGENT_FILE = "agents/{agent_name}/memory.json"
def _guide_text(path: Path) -> str:
return (REPO_ROOT / path).read_text(encoding="utf-8")
def _configuration_block(path: Path) -> str:
"""Return the first yaml example under the guide's Configuration heading."""
lines = _guide_text(path).splitlines()
starts = [index for index, line in enumerate(lines) if re.fullmatch(r"## (Configuration|配置)\s*", line)]
assert len(starts) == 1, f"expected exactly one Configuration heading in {path}, found {len(starts)}"
fences = [index for index in range(starts[0], len(lines)) if lines[index].strip().startswith("```yaml")]
assert fences, f"no yaml example after the Configuration heading in {path}"
end = next(index for index in range(fences[0] + 1, len(lines)) if lines[index].strip() == "```")
return "\n".join(lines[fences[0] + 1 : end])
def _top_level_memory_keys(block: str) -> set[str]:
"""Keys written directly under ``memory:`` (two-space indent) in a yaml example."""
keys = set()
inside = False
for line in block.splitlines():
if line.startswith("memory:"):
inside = True
continue
if not inside:
continue
if line and not line.startswith((" ", " ", "\t", "#")):
break
match = re.fullmatch(r" ([A-Za-z_][A-Za-z0-9_]*):.*", line)
if match:
keys.add(match.group(1))
return keys
@pytest.fixture(scope="module")
def probe_layout() -> tuple[str, str]:
"""The per-user manifest and per-agent facts paths, as the runtime resolves them."""
memory_path = memory_file_path(DeerMemConfig(storage_path=PROBE_ROOT), user_id=PROBE_USER_ID)
facts_path = agent_facts_directory(memory_path, PROBE_AGENT_NAME)
return str(memory_path.relative_to(PROBE_ROOT)), str(facts_path.relative_to(PROBE_ROOT))
@pytest.mark.parametrize("path", MEMORY_GUIDES, ids=str)
def test_guide_documents_the_scoped_manifest_and_facts_paths(path: Path, probe_layout: tuple[str, str]) -> None:
content = _guide_text(path)
memory_rel, facts_rel = probe_layout
# Guides spell the resolved layout with placeholders, so re-spell the real paths
# the same way before comparing. ``agent_facts_directory`` already folded the
# mixed-case probe name, hence the lowercase replacement.
def spelled(rel: str) -> str:
return rel.replace(PROBE_USER_ID, "{user_id}").replace(PROBE_AGENT_NAME.lower(), "{agent_name}")
scoped_manifest = spelled(memory_rel)
agent_facts = spelled(facts_rel)
assert scoped_manifest in content, f"{path} does not document the per-user manifest at {scoped_manifest}"
assert agent_facts in content, f"{path} does not document the per-agent facts directory at {agent_facts}"
assert LEGACY_PER_AGENT_FILE not in content, f"{path} still documents the pre-abstraction per-agent file {LEGACY_PER_AGENT_FILE}"
@pytest.mark.parametrize("path", MEMORY_GUIDES, ids=str)
def test_guide_keeps_backend_private_knobs_out_of_the_memory_top_level(path: Path) -> None:
keys = _top_level_memory_keys(_configuration_block(path))
assert keys, f"no keys parsed from the Configuration example in {path}"
shared = set(MemoryConfig.model_fields)
drift = sorted(keys - shared)
assert not drift, f"{path} documents DeerMem-private keys at the memory top level: {drift} (they belong under backend_config)"