From f374353da0f7d900b3eed011fcd0cc574e5acfe4 Mon Sep 17 00:00:00 2001 From: Osamaali313 Date: Sun, 5 Jul 2026 01:16:57 +0300 Subject: [PATCH] fix(memory): anchor LanceDB scope matching on path boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope filters in LanceDBStorage matched with a bare `scope LIKE 'prefix%'` (and, in reset, a lexicographic range `scope >= 'prefix' AND scope < 'prefix/ï¿¿'`). Neither respects the `/` path separator, so a sibling scope such as `/team-b` matches operations on `/team` because `-` (0x2D) sorts before `/` (0x2F). The effect on read paths (search, count, get_scope_info, list_records) is over-counting; on reset/forget it is data loss -- `reset("/team")` also deletes every record under the unrelated sibling `/team-b`. Route all four sites (search, delete, _scan_rows, reset) through a shared `_scope_prefix_clause` helper that anchors on the boundary -- the exact scope, or descendants under `prefix + "/"` -- matching the semantics the qdrant backend and this file's own get_scope_info/list_scopes already use. The delete path keeps its explicit `scope = '/'` root-record match and the existing single-quote escaping is preserved. --- .../crewai/memory/storage/lancedb_storage.py | 40 ++++++++++++++----- .../tests/memory/test_unified_memory.py | 26 ++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/lancedb_storage.py b/lib/crewai/src/crewai/memory/storage/lancedb_storage.py index 8c9d640a5d..60e1f4e581 100644 --- a/lib/crewai/src/crewai/memory/storage/lancedb_storage.py +++ b/lib/crewai/src/crewai/memory/storage/lancedb_storage.py @@ -368,6 +368,28 @@ def get_record(self, record_id: str) -> MemoryRecord | None: return None return self._row_to_record(rows[0]) + @staticmethod + def _scope_prefix_clause(scope_prefix: str) -> str: + """Build a SQL predicate matching a scope subtree by path boundary. + + A scope such as ``/team`` owns itself and every descendant under + ``/team/`` -- but *not* a lexicographic sibling like ``/team-b``. + Matching with a bare ``LIKE 'prefix%'`` (or a lexicographic range) + leaks into such siblings, so anchor the match on the ``/`` separator: + the exact scope, or anything beneath ``prefix + "/"``. + + Args: + scope_prefix: The scope path to match (already non-root). + + Returns: + A SQL boolean expression selecting the scope and its descendants. + """ + prefix = scope_prefix.rstrip("/") + if not prefix.startswith("/"): + prefix = "/" + prefix + safe = prefix.replace("'", "''") + return f"(scope = '{safe}' OR scope LIKE '{safe}/%')" + def search( self, query_embedding: list[float], @@ -385,9 +407,7 @@ def search( ) query = self._table.search(query_embedding) if scope_prefix is not None and scope_prefix.strip("/"): - prefix = scope_prefix.rstrip("/") - like_val = prefix + "%" - query = query.where(f"scope LIKE '{like_val}'") + query = query.where(self._scope_prefix_clause(scope_prefix)) results = query.limit( limit * 3 if (categories or metadata_filter) else limit ).to_list() @@ -448,10 +468,9 @@ def delete( return before - int(self._table.count_rows()) conditions = [] if scope_prefix is not None and scope_prefix.strip("/"): - prefix = scope_prefix.rstrip("/") - if not prefix.startswith("/"): - prefix = "/" + prefix - conditions.append(f"scope LIKE '{prefix}%' OR scope = '/'") + conditions.append( + f"({self._scope_prefix_clause(scope_prefix)} OR scope = '/')" + ) if older_than is not None: conditions.append(f"created_at < '{older_than.isoformat()}'") if not conditions: @@ -485,7 +504,7 @@ def _scan_rows( return [] q = self._table.search() if scope_prefix is not None and scope_prefix.strip("/"): - q = q.where(f"scope LIKE '{scope_prefix.rstrip('/')}%'") + q = q.where(self._scope_prefix_clause(scope_prefix)) if columns is not None: q = q.select(columns) result: list[dict[str, Any]] = q.limit(limit).to_list() @@ -609,10 +628,9 @@ def reset(self, scope_prefix: str | None = None) -> None: return if self._table is None: return - prefix = scope_prefix.rstrip("/") - if prefix: + if scope_prefix.rstrip("/"): self._do_write( - "delete", f"scope >= '{prefix}' AND scope < '{prefix}/\uffff'" + "delete", self._scope_prefix_clause(scope_prefix) ) def optimize(self) -> None: diff --git a/lib/crewai/tests/memory/test_unified_memory.py b/lib/crewai/tests/memory/test_unified_memory.py index 8a3d52e7a4..dacd89d6e9 100644 --- a/lib/crewai/tests/memory/test_unified_memory.py +++ b/lib/crewai/tests/memory/test_unified_memory.py @@ -179,6 +179,32 @@ def test_lancedb_list_scopes_get_scope_info(lancedb_path: Path) -> None: assert info.path == "/" +def test_lancedb_scope_prefix_respects_path_boundary(lancedb_path: Path) -> None: + """Scope operations must not leak into lexicographic sibling scopes. + + ``/team`` owns itself and descendants under ``/team/`` but is a distinct + scope from the sibling ``/team-b`` (which merely shares a string prefix). + """ + from crewai.memory.storage.lancedb_storage import LanceDBStorage + + storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4) + storage.save([ + MemoryRecord(content="team", scope="/team", embedding=[0.1] * 4), + MemoryRecord(content="team-b", scope="/team-b", embedding=[0.2] * 4), + MemoryRecord(content="child", scope="/team/x", embedding=[0.3] * 4), + ]) + + # count/get_scope_info must include the scope + its descendants only. + assert storage.count("/team") == 2 # /team and /team/x, NOT /team-b + assert storage.count("/team-b") == 1 + + # reset must delete only the /team subtree, leaving the sibling intact. + storage.reset("/team") + assert storage.count("/team-b") == 1 + surviving = {r.scope for r in storage.list_records(limit=100)} + assert surviving == {"/team-b"} + + @pytest.fixture