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