mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
303 lines
11 KiB
Python
303 lines
11 KiB
Python
|
|
"""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"]
|