fix: guard None metadata/doc in tool_check_duplicate and Layer1/Layer2

Chroma 1.5.x can return ``None`` inside the ``metadatas`` / ``documents``
lists of a query/get result for partially-flushed rows. The codebase
already has a systemic None-guard pattern (merged #999, #1013, #1019)
but three call sites were still unguarded:

* ``mcp_server.tool_check_duplicate`` (``mcp_server.py:487-488``) —
  ``meta = results["metadatas"][0][i]`` followed by ``meta.get(...)``
  raises ``AttributeError: 'NoneType' object has no attribute 'get'``.
  The broad ``except Exception`` wrapper (line 504) swallows it and
  returns an uninformative ``"Duplicate check failed"``.

* ``layers.Layer1.generate`` (``layers.py:126``) — iterates
  ``zip(docs, metas)`` and calls ``meta.get(key)`` in the importance
  loop. A single None metadata blows up the entire wake-up render.

* ``layers.Layer2.retrieve`` (``layers.py:224``) — same pattern, same
  crash path for the on-demand render.

Apply the same ``meta = meta or {}`` / ``doc = doc or ""`` idiom used
by the merged guards in the search path. Three-line additions, no
behaviour change on well-formed results.

Tests added:

* ``test_check_duplicate_handles_none_metadata`` — mocks the collection
  query to return ``None`` for one metadata and document, asserts the
  call does not crash and the sentinel-rendered entry has wing/room "?"
  and empty content.
* ``test_layer1_handles_none_metadata`` / ``_handles_none_document``
* ``test_layer2_handles_none_metadata``

Relationship to other open PRs:

* **#1019** guarded ``searcher.py`` loops. This PR extends the same
  guard to the three call sites #1019 did not touch.
* **#979** fixed ``tool_check_duplicate`` negative similarity but left
  the None-metadata path unguarded.
* Does not overlap **#1013** (``Layer3.search_raw``) or **#999**.
This commit is contained in:
eldar702
2026-04-19 11:13:50 +03:00
parent 109d7f267c
commit 4949aab68b
4 changed files with 113 additions and 2 deletions
+69
View File
@@ -655,3 +655,72 @@ def test_memory_stack_status_with_palace(tmp_path):
assert result["total_drawers"] == 42
assert result["L0_identity"]["exists"] is True
# ── Layer1 / Layer2 None-metadata guards ───────────────────────────────
#
# Chroma 1.5.x can return ``None`` inside the ``metadatas`` / ``documents``
# lists for partially-flushed rows. The Layer1.generate() and
# Layer2.retrieve() loops previously called ``meta.get(...)`` without
# coercing, raising ``AttributeError: 'NoneType' object has no attribute
# 'get'`` and blowing up the whole wake-up render. These tests guard that
# the loops tolerate the None entries and render the rest of the result.
def test_layer1_handles_none_metadata():
"""Layer1.generate tolerates None entries in the metadatas list."""
docs = ["important memory", "another memory"]
metas = [{"room": "decisions", "source_file": "a.txt"}, None]
mock_col = _mock_chromadb_for_layer(docs, metas)
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
# Should not raise AttributeError on the None entry.
result = layer.generate()
assert "ESSENTIAL STORY" in result
assert "important memory" in result
def test_layer1_handles_none_document():
"""Layer1.generate tolerates None entries in the documents list."""
docs = ["first doc", None]
metas = [
{"room": "r", "source_file": "a.txt"},
{"room": "r", "source_file": "b.txt"},
]
mock_col = _mock_chromadb_for_layer(docs, metas)
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
result = layer.generate()
assert result # Render succeeded despite the None document.
def test_layer2_handles_none_metadata():
"""Layer2.retrieve tolerates None entries in the metadatas list."""
mock_col = MagicMock()
mock_col.get.return_value = {
"documents": ["first doc", "second doc"],
"metadatas": [{"room": "r", "source_file": "a.txt"}, None],
}
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
# Should not raise AttributeError on the None entry.
result = layer.retrieve()
assert "L2 — ON-DEMAND" in result