mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(memory): sanitize entity_refs to prevent dict-shaped entries crashing search (#2951)
## Description Closes #2947 `entity_refs` is annotated `list[str]` everywhere, but nothing enforced that at runtime. `LocalBackend.save_memory`'s `entities` argument is filled straight from LLM-supplied `memory_save` tool input (`headroom/memory/system.py:575` into `memory_handler.py:1242`), so a caller can pass the typed `{"entity": ..., "entity_type": ...}` shape, which is the format `extracted_entities` expects, into it by mistake. Those dicts were then persisted verbatim into `entity_refs`, both in the `memories` table and in the duplicated copy the vector index keeps for post-filtering. Every later `search_memories` call does `set().update(memory.entity_refs)` while collecting entities for graph expansion. Hashing a dict raises `TypeError: unhashable type: 'dict'`, and because that happens inside the vector-result loop rather than per-item, **one** poisoned row aborted the **entire** search. The proxy's memory handler catches the exception and returns no memories, so recall went quietly dark rather than failing loudly, and the bad row kept re-appearing in top-k for related queries, so it stayed dark. The issue reporter hit this in production: 4 bad rows disabled memory search for a whole project for a day, with nothing visible to the end user beyond a swallowed warning in `proxy.log`. The same root cause has two more crash modes, both confirmed below: `AttributeError: 'dict' object has no attribute 'lower'` during graph linking on the save path, and the same error in the `entities` search filter (`ref.lower()`). The fix adds one helper and applies it at both ends of the data flow. Dicts are **unwrapped to their `entity` name** rather than dropped, so rows that are already corrupted keep contributing to graph expansion instead of silently losing their entities. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **New helper `normalize_entity_refs()` in `headroom/memory/models.py`.** Coerces a raw entity-reference list into the `list[str]` it claims to be: strings pass through, dicts are unwrapped via their `entity` (or `name`) key, and anything with no recoverable name is dropped rather than stringified, since a ref like `"{'entity_type': 'project'}"` would only pollute the graph. Order is preserved and duplicate names are collapsed. - **Write path, to stop new corruption at the door.** `LocalBackend.save_memory` normalizes `entities` before it reaches `entity_refs` and graph linking. `LocalBackend.search_memories` normalizes the `entities` *filter* argument too, since it arrives from the same untrusted tool input (`memory_handler.py:1320`). - **Read path, to heal rows that were written before this fix.** Applied at the three deserialization boundaries, so no data migration is needed and corrupted rows normalize themselves the next time they are loaded: `Memory.from_dict` (`headroom/memory/models.py`), `SQLiteMemoryStore._row_to_memory` (`headroom/memory/adapters/sqlite.py`), and the vector indexes' own `entity_refs` copies used for post-filtering, `VectorMetadata.from_json` (`headroom/memory/adapters/sqlite_vector.py`) and `IndexedMemoryMetadata.from_dict` (`headroom/memory/adapters/hnsw.py`). - **Defensive normalization on emitted results.** `search_memories` and `text_search` normalize the refs they return as `related_entities`, so a backend that produces `Memory` objects by some path not covered above still cannot take a whole query down, and callers never receive a dict where they expect an entity name. **Note on scope versus the patch proposed in the issue.** The issue proposed normalizing in two places (`save_memory` plus the `set().update()` line). I widened it slightly because that pair leaves three related failures live: the `entities` filter still crashes on `ref.lower()`, `related_entities` still hands dicts back to the caller, and, most importantly, already-poisoned rows stay poisoned in storage. Normalizing at the deserialization boundaries fixes all three at once and is what makes existing corrupted databases recover on their own. **Behavior change worth flagging.** `entity_refs` is now de-duplicated (case-sensitively) on both save and load. Refs were already treated as a set for graph expansion, so this is semantically a no-op, but it is a visible difference if anything asserts on exact list contents. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed New file `tests/test_memory/test_entity_ref_sanitization.py` adds 10 tests covering the helper, both write paths, all three deserialization boundaries, and the three crash modes. ### Test Output ```text $ python -m pytest tests/test_memory/test_entity_ref_sanitization.py -q .......... [100%] 10 passed, 17 warnings in 0.34s ``` Full memory suite, plus a before/after comparison of the failure set to prove no regressions: ```text $ python -m pytest tests/test_memory/ -q 13 failed, 576 passed, 3 skipped, 1072 warnings, 25 errors in 44.74s # the same run with the source changes stashed (baseline on upstream/main @941c25d3): 13 failed, 566 passed, 3 skipped, 1055 warnings, 25 errors in 44.92s # diff of the failing/erroring test IDs, before versus after: $ diff baseline.txt after.txt && echo "NO NEW FAILURES vs baseline" NO NEW FAILURES vs baseline ``` 576 passed equals the 566 baseline plus the 10 new tests. The 13 failures and 25 errors are pre-existing on `upstream/main` and unrelated to this change: they are Windows-only temp-directory cleanup failures in this local environment. ```text E PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'C:\Users\...\Temp\tmp_qnqtami\test.db' ``` Adjacent suites that construct `Memory` objects: ```text $ python -m pytest tests/test_memory_system.py tests/test_memory_eval.py tests/test_critical_gaps.py -q 158 passed, 1 skipped, 514 warnings in 20.58s ``` Lint and format on the changed files: ```text $ ruff check headroom/memory tests/test_memory/test_entity_ref_sanitization.py All checks passed! $ ruff format --check headroom/memory tests/test_memory/test_entity_ref_sanitization.py 48 files already formatted ``` `mypy headroom --ignore-missing-imports --python-version 3.13` reports 12 errors, all pre-existing on `upstream/main` and all in files this PR does not touch (`headroom/ccr/mcp_server.py`, `headroom/memory/mcp_server.py`, `headroom/release_version.py`; they come from a local MCP SDK version mismatch). Zero errors in any changed file. ## Real Behavior Proof - Environment: Windows 11, Python 3.13.11, pytest 9.1.1, ruff 0.15.17, local `headroom._core` built. Branched from `upstream/main` at `941c25d3`, the same branch point the issue reports. - Exact command / steps: Ran a standalone script (not a mock-only test) driving `LocalBackend.search_memories` and `LocalBackend.save_memory` with `entity_refs=[{"entity": "Project X", "entity_type": "project"}]`, first against unmodified `941c25d3` and then against this branch. Three scenarios: vector search with graph expansion, search with an `entities` filter, and a save carrying dict-shaped `entities`. - Observed result: on unmodified `941c25d3` all three crashed, printing `SEARCH: TypeError: unhashable type: 'dict'`, `FILTER: TypeError: unhashable type: 'dict'`, and `SAVE: AttributeError: 'dict' object has no attribute 'lower'`. With this branch applied all three succeed: search returns both the poisoned and the clean memory with `related_entities == ["Project X"]`, the filter matches the recovered name, and the save persists `entity_refs == ["Project X"]`. Those three scenarios are now the regression tests in `test_entity_ref_sanitization.py`. - Not tested: no live end-to-end run through the MCP `memory_save` tool against a real LLM, and no test against a real pre-existing SQLite database containing dict-shaped rows. The healing-on-load path is covered at the deserialization functions (`Memory.from_dict`, `VectorMetadata.from_json`, `IndexedMemoryMetadata.from_dict`) rather than through an actual corrupted `.db` file. The non-local backends (`mem0`, `direct_mem0`, `qdrant-neo4j`, `cognee`) were not exercised; this PR only changes the local backend and the shared models and adapters. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`, it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes Documentation was not changed because `normalize_entity_refs()` is an internal helper and no public API or user-facing behavior changes; `entity_refs` still behaves exactly as its existing `list[str]` contract always documented. Credit for the diagnosis, the root-cause analysis, and the original repro goes to @apacheco-RT in #2947, who could not open a PR directly because GitHub blocks Enterprise Managed User accounts from forking outside their enterprise. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
This commit is contained in:
parent
6147883d5e
commit
2d1e96b85c
6 changed files with 381 additions and 16 deletions
|
|
@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
|
|||
|
||||
import numpy as np
|
||||
|
||||
from ..models import Memory, ScopeLevel
|
||||
from ..models import Memory, ScopeLevel, normalize_entity_refs
|
||||
from ..ports import VectorFilter, VectorSearchResult
|
||||
|
||||
# hnswlib is optional - may not compile on all platforms
|
||||
|
|
@ -139,7 +139,9 @@ class IndexedMemoryMetadata:
|
|||
valid_until=(
|
||||
datetime.fromisoformat(data["valid_until"]) if data.get("valid_until") else None
|
||||
),
|
||||
entity_refs=data.get("entity_refs", []),
|
||||
# Normalized on load so rows written before #2947 was fixed heal
|
||||
# themselves instead of crashing search.
|
||||
entity_refs=normalize_entity_refs(data.get("entity_refs")),
|
||||
content=data["content"],
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
importance=data.get("importance", 0.5),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from datetime import datetime, timezone
|
|||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..models import Memory, ScopeLevel
|
||||
from ..models import Memory, ScopeLevel, normalize_entity_refs
|
||||
from ..ports import MemoryFilter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -227,7 +227,11 @@ class SQLiteMemoryStore:
|
|||
last_accessed=datetime.fromisoformat(row["last_accessed"])
|
||||
if row["last_accessed"]
|
||||
else None,
|
||||
entity_refs=json.loads(row["entity_refs"]) if row["entity_refs"] else [],
|
||||
# Normalized on load so rows written before #2947 was fixed heal
|
||||
# themselves instead of crashing search.
|
||||
entity_refs=normalize_entity_refs(
|
||||
json.loads(row["entity_refs"]) if row["entity_refs"] else []
|
||||
),
|
||||
embedding=self._deserialize_embedding(row["embedding"]),
|
||||
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from typing import TYPE_CHECKING, Any, cast
|
|||
|
||||
import numpy as np
|
||||
|
||||
from ..models import Memory, ScopeLevel
|
||||
from ..models import Memory, ScopeLevel, normalize_entity_refs
|
||||
from ..ports import VectorFilter, VectorSearchResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -135,7 +135,9 @@ class VectorMetadata:
|
|||
valid_until=(
|
||||
datetime.fromisoformat(d["valid_until"]) if d.get("valid_until") else None
|
||||
),
|
||||
entity_refs=d.get("entity_refs", []),
|
||||
# Normalized on load so rows written before #2947 was fixed heal
|
||||
# themselves instead of crashing search.
|
||||
entity_refs=normalize_entity_refs(d.get("entity_refs")),
|
||||
content=d["content"],
|
||||
created_at=datetime.fromisoformat(d["created_at"]),
|
||||
importance=d.get("importance", 0.5),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from pathlib import Path
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from headroom.memory.adapters.graph_models import Entity, Relationship, Subgraph
|
||||
from headroom.memory.models import Memory
|
||||
from headroom.memory.models import Memory, normalize_entity_refs
|
||||
from headroom.memory.ports import MemorySearchResult
|
||||
from headroom.models.config import ML_MODEL_DEFAULTS
|
||||
|
||||
|
|
@ -284,8 +284,15 @@ class LocalBackend:
|
|||
# Determine if using pre-extraction mode
|
||||
has_pre_extraction = bool(facts or extracted_entities or extracted_relationships)
|
||||
|
||||
# Merge entity names from both simple and typed formats
|
||||
all_entity_names: list[str] = list(entities) if entities else []
|
||||
# Merge entity names from both simple and typed formats.
|
||||
#
|
||||
# `entities` is typed list[str], but it is populated straight from
|
||||
# LLM-supplied memory_save tool arguments, and callers do sometimes
|
||||
# pass the typed {"entity": ..., "entity_type": ...} shape here (that
|
||||
# is what extracted_entities is for). Normalizing keeps those dicts out
|
||||
# of entity_refs, where they used to crash every later search that
|
||||
# retrieved the row -- see issue #2947.
|
||||
all_entity_names: list[str] = normalize_entity_refs(entities)
|
||||
entity_types: dict[str, str] = {}
|
||||
|
||||
if extracted_entities:
|
||||
|
|
@ -448,13 +455,17 @@ class LocalBackend:
|
|||
continue
|
||||
|
||||
seen_memory_ids.add(vr.memory.id)
|
||||
all_entity_refs.update(vr.memory.entity_refs)
|
||||
# Defense in depth: the storage adapters normalize entity_refs on
|
||||
# load, but a backend that hands us Memory objects some other way
|
||||
# must not be able to abort the whole search with one bad row.
|
||||
entity_refs = normalize_entity_refs(vr.memory.entity_refs)
|
||||
all_entity_refs.update(entity_refs)
|
||||
|
||||
results.append(
|
||||
MemorySearchResult(
|
||||
memory=vr.memory,
|
||||
score=vr.similarity,
|
||||
related_entities=list(vr.memory.entity_refs),
|
||||
related_entities=entity_refs,
|
||||
related_memories=[],
|
||||
)
|
||||
)
|
||||
|
|
@ -503,15 +514,17 @@ class LocalBackend:
|
|||
MemorySearchResult(
|
||||
memory=memory,
|
||||
score=0.5, # Default score for graph-expanded results
|
||||
related_entities=list(memory.entity_refs),
|
||||
related_entities=normalize_entity_refs(memory.entity_refs),
|
||||
related_memories=[],
|
||||
)
|
||||
)
|
||||
seen_memory_ids.add(mem_id)
|
||||
|
||||
# Filter by specified entities if provided
|
||||
# Filter by specified entities if provided. Like the save path, this
|
||||
# argument arrives from LLM-supplied tool input, so it gets the same
|
||||
# normalization rather than trusting its list[str] annotation.
|
||||
if entities:
|
||||
entities_lower = {e.lower() for e in entities}
|
||||
entities_lower = {e.lower() for e in normalize_entity_refs(entities)}
|
||||
results = [
|
||||
r
|
||||
for r in results
|
||||
|
|
@ -826,7 +839,7 @@ class LocalBackend:
|
|||
MemorySearchResult(
|
||||
memory=tr.memory,
|
||||
score=tr.score,
|
||||
related_entities=list(tr.memory.entity_refs),
|
||||
related_entities=normalize_entity_refs(tr.memory.entity_refs),
|
||||
related_memories=[],
|
||||
)
|
||||
for tr in text_results
|
||||
|
|
|
|||
|
|
@ -14,6 +14,46 @@ except ImportError:
|
|||
np = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def normalize_entity_refs(values: Any) -> list[str]:
|
||||
"""Coerce a raw entity-reference list into the plain ``list[str]`` it claims to be.
|
||||
|
||||
``entity_refs`` (and the ``entities`` argument that feeds it) is typed
|
||||
``list[str]``, but nothing enforced that at runtime, so callers have
|
||||
persisted the typed ``{"entity": ..., "entity_type": ...}`` shape -- the
|
||||
format ``extracted_entities`` expects -- into it by mistake. Those dicts
|
||||
then break every consumer that treats a ref as a string: ``set().update()``
|
||||
raises ``TypeError: unhashable type: 'dict'`` and ``ref.lower()`` raises
|
||||
``AttributeError``, which took down whole memory searches rather than the
|
||||
one bad row (see issue #2947).
|
||||
|
||||
Dicts are unwrapped to their ``entity`` name so no information is lost;
|
||||
anything with no recoverable name is dropped rather than stringified, since
|
||||
a ref like ``"{'entity_type': 'project'}"`` would only pollute the graph.
|
||||
Order is preserved and duplicate names are collapsed.
|
||||
"""
|
||||
if not values:
|
||||
return []
|
||||
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for value in values:
|
||||
if isinstance(value, str):
|
||||
name = value
|
||||
elif isinstance(value, dict):
|
||||
# The extracted_entities shape, mistakenly used as a plain name.
|
||||
candidate = value.get("entity") or value.get("name")
|
||||
name = candidate if isinstance(candidate, str) else ""
|
||||
else:
|
||||
name = ""
|
||||
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
normalized.append(name)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
class ScopeLevel(Enum):
|
||||
"""Memory scope hierarchy levels."""
|
||||
|
||||
|
|
@ -132,7 +172,9 @@ class Memory:
|
|||
last_accessed=datetime.fromisoformat(data["last_accessed"])
|
||||
if data.get("last_accessed")
|
||||
else None,
|
||||
entity_refs=data.get("entity_refs", []),
|
||||
# Normalized on load so rows written before #2947 was fixed heal
|
||||
# themselves instead of crashing search.
|
||||
entity_refs=normalize_entity_refs(data.get("entity_refs")),
|
||||
embedding=embedding,
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
|
|
|||
302
tests/test_memory/test_entity_ref_sanitization.py
Normal file
302
tests/test_memory/test_entity_ref_sanitization.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"""Regression tests for entity_refs type safety.
|
||||
|
||||
`entity_refs` is typed `list[str]` everywhere, but nothing enforced that at
|
||||
runtime. A caller that mistakenly passed the typed
|
||||
`{"entity": ..., "entity_type": ...}` shape (the format `extracted_entities`
|
||||
expects) into the plain `entities` field of `save_memory` got those dicts
|
||||
persisted verbatim into `entity_refs` -- both in the `memories` table and in
|
||||
the duplicated copy the vector index keeps for post-filtering.
|
||||
|
||||
Every later `search_memories` call does `set().update(memory.entity_refs)`
|
||||
while collecting entities for graph expansion. Hashing a dict raises
|
||||
`TypeError: unhashable type: 'dict'`, and because that happens inside the
|
||||
vector-result loop (not guarded per-item) it aborted the *entire* search for
|
||||
any query whose top-k included one poisoned row. The proxy's memory handler
|
||||
swallows the exception and returns no memories, so recall went quietly dark
|
||||
rather than failing loudly.
|
||||
|
||||
See https://github.com/headroomlabs-ai/headroom/issues/2947.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.memory.adapters.hnsw import IndexedMemoryMetadata
|
||||
from headroom.memory.adapters.sqlite_vector import VectorMetadata
|
||||
from headroom.memory.backends.local import LocalBackend
|
||||
from headroom.memory.models import Memory, normalize_entity_refs
|
||||
|
||||
# The malformed shape that started all of this: the extracted_entities format
|
||||
# passed into a field that expects plain names.
|
||||
DICT_REF = {"entity": "Project X", "entity_type": "project"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The helper itself
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_normalize_entity_refs_unwraps_dicts_and_drops_junk() -> None:
|
||||
"""Dicts are unwrapped to their name; anything unusable is dropped."""
|
||||
assert normalize_entity_refs(["Alice", DICT_REF]) == ["Alice", "Project X"]
|
||||
|
||||
# Nothing usable in these: no name to recover, so they are dropped rather
|
||||
# than stringified into garbage entity names like "{'foo': 'bar'}".
|
||||
assert normalize_entity_refs([{"entity_type": "project"}, {}, None, 42, ""]) == []
|
||||
|
||||
# Common no-op cases stay untouched.
|
||||
assert normalize_entity_refs(["Alice", "Bob"]) == ["Alice", "Bob"]
|
||||
assert normalize_entity_refs(None) == []
|
||||
assert normalize_entity_refs([]) == []
|
||||
|
||||
|
||||
def test_normalize_entity_refs_preserves_order_and_deduplicates() -> None:
|
||||
"""A name already present is not appended twice, and order is stable."""
|
||||
assert normalize_entity_refs(["Alice", DICT_REF, "Alice", "Project X"]) == [
|
||||
"Alice",
|
||||
"Project X",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Write path: stop new corruption at the door
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_memory_sanitizes_dict_shaped_entities_param() -> None:
|
||||
"""`entities` items that are dicts get coerced to plain names before storage."""
|
||||
backend = LocalBackend()
|
||||
backend._initialized = True
|
||||
|
||||
saved: list[Memory] = []
|
||||
|
||||
async def fake_add(**kwargs: object) -> Memory:
|
||||
memory = Memory(
|
||||
id="new-memory",
|
||||
content=str(kwargs["content"]),
|
||||
user_id=str(kwargs["user_id"]),
|
||||
entity_refs=list(kwargs["entity_refs"]), # type: ignore[arg-type]
|
||||
)
|
||||
saved.append(memory)
|
||||
return memory
|
||||
|
||||
backend._hierarchical_memory = SimpleNamespace(add=AsyncMock(side_effect=fake_add))
|
||||
backend._graph = SimpleNamespace(
|
||||
get_entity_by_name=AsyncMock(return_value=None),
|
||||
add_entity=AsyncMock(return_value=SimpleNamespace(id="entity-id")),
|
||||
add_relationship=AsyncMock(),
|
||||
)
|
||||
|
||||
# Without the fix this raises AttributeError: 'dict' object has no
|
||||
# attribute 'lower' during graph linking.
|
||||
await backend.save_memory(
|
||||
content="Alice manages Project X",
|
||||
user_id="alice",
|
||||
entities=[DICT_REF], # type: ignore[list-item]
|
||||
)
|
||||
|
||||
assert saved[0].entity_refs == ["Project X"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_memory_merges_dict_entities_with_extracted_entities() -> None:
|
||||
"""A name arriving through both `entities` and `extracted_entities` is stored once."""
|
||||
backend = LocalBackend()
|
||||
backend._initialized = True
|
||||
|
||||
saved: list[Memory] = []
|
||||
|
||||
async def fake_add(**kwargs: object) -> Memory:
|
||||
memory = Memory(
|
||||
id="new-memory",
|
||||
content=str(kwargs["content"]),
|
||||
user_id=str(kwargs["user_id"]),
|
||||
entity_refs=list(kwargs["entity_refs"]), # type: ignore[arg-type]
|
||||
)
|
||||
saved.append(memory)
|
||||
return memory
|
||||
|
||||
backend._hierarchical_memory = SimpleNamespace(add=AsyncMock(side_effect=fake_add))
|
||||
backend._graph = SimpleNamespace(
|
||||
get_entity_by_name=AsyncMock(return_value=None),
|
||||
add_entity=AsyncMock(return_value=SimpleNamespace(id="entity-id")),
|
||||
add_relationship=AsyncMock(),
|
||||
)
|
||||
|
||||
await backend.save_memory(
|
||||
content="Alice manages Project X",
|
||||
user_id="alice",
|
||||
entities=[DICT_REF], # type: ignore[list-item]
|
||||
extracted_entities=[{"entity": "Project X", "entity_type": "project"}],
|
||||
)
|
||||
|
||||
assert saved[0].entity_refs == ["Project X"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Read path: heal rows that were already written before the fix
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_memory_from_dict_heals_stored_dict_refs() -> None:
|
||||
"""Rows persisted before the fix load as plain names instead of dicts."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
memory = Memory.from_dict(
|
||||
{
|
||||
"id": "poisoned-memory",
|
||||
"content": "Alice manages Project X",
|
||||
"user_id": "alice",
|
||||
"created_at": now,
|
||||
"valid_from": now,
|
||||
"importance": 0.5,
|
||||
"entity_refs": [DICT_REF, "Alice"],
|
||||
}
|
||||
)
|
||||
|
||||
assert memory.entity_refs == ["Project X", "Alice"]
|
||||
|
||||
|
||||
def test_vector_metadata_from_json_heals_stored_dict_refs() -> None:
|
||||
"""The vector index keeps its own copy of entity_refs; heal that one too."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
metadata = VectorMetadata.from_json(
|
||||
json.dumps(
|
||||
{
|
||||
"memory_id": "poisoned-memory",
|
||||
"user_id": "alice",
|
||||
"session_id": None,
|
||||
"agent_id": None,
|
||||
"valid_until": None,
|
||||
"entity_refs": [DICT_REF],
|
||||
"content": "Alice manages Project X",
|
||||
"created_at": now,
|
||||
"importance": 0.5,
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert metadata.entity_refs == ["Project X"]
|
||||
assert metadata.to_memory().entity_refs == ["Project X"]
|
||||
|
||||
|
||||
def test_indexed_memory_metadata_from_dict_heals_stored_dict_refs() -> None:
|
||||
"""Same for the HNSW index's metadata copy."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
metadata = IndexedMemoryMetadata.from_dict(
|
||||
{
|
||||
"memory_id": "poisoned-memory",
|
||||
"user_id": "alice",
|
||||
"session_id": None,
|
||||
"agent_id": None,
|
||||
"valid_until": None,
|
||||
"entity_refs": [DICT_REF],
|
||||
"content": "Alice manages Project X",
|
||||
"created_at": now,
|
||||
"importance": 0.5,
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert metadata.entity_refs == ["Project X"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Search: a single bad row must not take the whole query down
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _backend_with_results(memories: list[Memory]) -> LocalBackend:
|
||||
backend = LocalBackend()
|
||||
backend._initialized = True
|
||||
backend._hierarchical_memory = SimpleNamespace(
|
||||
search=AsyncMock(return_value=[SimpleNamespace(memory=m, similarity=0.9) for m in memories])
|
||||
)
|
||||
backend._graph = SimpleNamespace(
|
||||
get_entity_by_name=AsyncMock(return_value=None),
|
||||
query_subgraph=AsyncMock(return_value=SimpleNamespace(entities=[], relationships=[])),
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories_tolerates_dict_shaped_entity_refs() -> None:
|
||||
"""A single legacy/corrupted row with dict entity_refs must not crash search."""
|
||||
poisoned = Memory(
|
||||
id="poisoned-memory",
|
||||
content="Alice manages Project X",
|
||||
user_id="alice",
|
||||
entity_refs=[DICT_REF], # type: ignore[list-item]
|
||||
)
|
||||
clean = Memory(
|
||||
id="clean-memory",
|
||||
content="Bob manages Project Y",
|
||||
user_id="alice",
|
||||
entity_refs=["Project Y"],
|
||||
)
|
||||
backend = _backend_with_results([poisoned, clean])
|
||||
|
||||
# Without the fix this raises TypeError: unhashable type: 'dict'.
|
||||
results = await backend.search_memories("Alice's work", "alice", include_related=True)
|
||||
|
||||
assert [r.memory.id for r in results] == ["poisoned-memory", "clean-memory"]
|
||||
# The recovered name is still usable for graph expansion and is reported
|
||||
# back to the caller as a plain string, not a dict.
|
||||
assert results[0].related_entities == ["Project X"]
|
||||
backend._graph.get_entity_by_name.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories_entity_filter_matches_healed_refs() -> None:
|
||||
"""The `entities` filter lowercases each ref, which dicts also break.
|
||||
|
||||
On unfixed code this never gets that far -- the unconditional
|
||||
`set().update()` above raises first -- but once refs are strings again the
|
||||
filter has to actually match the recovered name.
|
||||
"""
|
||||
poisoned = Memory(
|
||||
id="poisoned-memory",
|
||||
content="Alice manages Project X",
|
||||
user_id="alice",
|
||||
entity_refs=[DICT_REF], # type: ignore[list-item]
|
||||
)
|
||||
backend = _backend_with_results([poisoned])
|
||||
|
||||
results = await backend.search_memories(
|
||||
"Alice's work",
|
||||
"alice",
|
||||
include_related=False,
|
||||
entities=["project x"],
|
||||
)
|
||||
|
||||
assert [r.memory.id for r in results] == ["poisoned-memory"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories_tolerates_dict_shaped_entities_filter() -> None:
|
||||
"""The filter argument comes from LLM tool input too, so it can be malformed."""
|
||||
clean = Memory(
|
||||
id="clean-memory",
|
||||
content="Alice manages Project X",
|
||||
user_id="alice",
|
||||
entity_refs=["Project X"],
|
||||
)
|
||||
backend = _backend_with_results([clean])
|
||||
|
||||
# Without normalization this raises AttributeError: 'dict' object has no
|
||||
# attribute 'lower' while building the filter set.
|
||||
results = await backend.search_memories(
|
||||
"Alice's work",
|
||||
"alice",
|
||||
include_related=False,
|
||||
entities=[DICT_REF], # type: ignore[list-item]
|
||||
)
|
||||
|
||||
assert [r.memory.id for r in results] == ["clean-memory"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue