Skip to content

fix(memory): anchor LanceDB scope matching on path boundary#6459

Open
Osamaali313 wants to merge 1 commit into
crewAIInc:mainfrom
Osamaali313:fix/lancedb-scope-boundary
Open

fix(memory): anchor LanceDB scope matching on path boundary#6459
Osamaali313 wants to merge 1 commit into
crewAIInc:mainfrom
Osamaali313:fix/lancedb-scope-boundary

Conversation

@Osamaali313

Copy link
Copy Markdown

Problem

Scope filtering in LanceDBStorage does not respect the / path separator, so operations on a scope leak into lexicographic sibling scopes:

  • read paths (search, _scan_rowscount / get_scope_info / list_records / list_categories) used scope LIKE 'prefix%'
  • delete used scope LIKE 'prefix%' OR scope = '/'
  • reset used the range scope >= 'prefix' AND scope < 'prefix/�'

Because - (0x2D) sorts before / (0x2F), a sibling like /team-b matches an operation scoped to /team.

On read paths this over-counts. On reset/forget it is data loss: reset("/team") also deletes every record under the unrelated /team-b.

storage.count("/team")   # -> 3  (should be 2: /team and /team/x, NOT /team-b)
storage.reset("/team")   # also wipes /team-b

Fix

Route all four sites through a shared helper that anchors the match on the path boundary — the exact scope, or descendants under prefix + "/":

@staticmethod
def _scope_prefix_clause(scope_prefix: str) -> str:
    prefix = scope_prefix.rstrip("/")
    if not prefix.startswith("/"):
        prefix = "/" + prefix
    safe = prefix.replace("'", "''")
    return f"(scope = '{safe}' OR scope LIKE '{safe}/%')"

This matches the hierarchical semantics the qdrant backend (_build_scope_filter) and this file's own get_scope_info / list_scopes already use (child_prefix = prefix + "/"). delete keeps its explicit scope = '/' root-record match, and the existing single-quote escaping is preserved (orthogonal to #5729).

Tests

Added test_lancedb_scope_prefix_respects_path_boundary (in lib/crewai/tests/memory/test_unified_memory.py), using explicit embeddings so it needs no network. It asserts count("/team") == 2, count("/team-b") == 1, and that reset("/team") leaves /team-b intact.

  • Before the fix the test fails: count("/team") returns 3 (assert 3 == 2).
  • After the fix it passes; existing memory tests are unaffected.

Contribution note: AI tooling helped surface and reproduce this scope-boundary bug; I reviewed, verified (before/after unit test), and finalized the fix myself. Tagging as llm-generated per the contribution policy.

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.
Copilot AI review requested due to automatic review settings July 4, 2026 22:17
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change introduces a boundary-anchored SQL predicate helper, _scope_prefix_clause, in LanceDBStorage to correctly match a scope and its descendants without matching lexicographic siblings. The helper replaces prior LIKE/range-based filters in search(), delete(), _scan_rows(), and reset(). A new test validates the fix.

Changes

LanceDB Scope Prefix Matching

Layer / File(s) Summary
Scope prefix clause helper and usage sites
lib/crewai/src/crewai/memory/storage/lancedb_storage.py
Adds a static _scope_prefix_clause helper that normalizes the scope prefix, escapes quotes, and builds a predicate matching the exact scope or descendants under prefix + "/"; search(), delete(), _scan_rows(), and reset() now use this helper instead of raw LIKE patterns or lexicographic range conditions, with delete() additionally OR'ing an explicit root-scope condition.
Path boundary regression test
lib/crewai/tests/memory/test_unified_memory.py
Adds test_lancedb_scope_prefix_respects_path_boundary, which saves records under /team, /team-b, and /team/x, then asserts count() and reset() correctly distinguish true subtree descendants from lexicographic siblings.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant LanceDBStorage
  participant ScopePrefixClause as "_scope_prefix_clause"
  participant LanceDBTable

  Caller->>LanceDBStorage: search/delete/reset(scope_prefix)
  LanceDBStorage->>ScopePrefixClause: build predicate(scope_prefix)
  ScopePrefixClause-->>LanceDBStorage: SQL predicate string
  LanceDBStorage->>LanceDBTable: apply predicate as filter
  LanceDBTable-->>LanceDBStorage: matching rows
  LanceDBStorage-->>Caller: results
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: path-boundary-safe LanceDB scope matching.
Description check ✅ Passed The description directly explains the bug, fix, and tests for the LanceDB scope boundary issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Osamaali313

Copy link
Copy Markdown
Author

Note for maintainers: per the contribution policy this PR should carry the llm-generated label — AI tooling helped surface and reproduce the bug, and I reviewed/verified (RED→GREEN unit test) and finalized it. I don't have permission to apply the label on a fork PR, so could a maintainer please add it? Happy to adjust anything.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
lib/crewai/tests/memory/test_unified_memory.py (1)

191-199: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider adding an underscore-sibling case to guard the LIKE-escaping gap.

The current fixtures (/team, /team-b) don't exercise LIKE wildcard characters. Adding a scope like /team_a alongside a same-length sibling (e.g. /teamZa) would catch the unescaped _ wildcard leakage flagged in lancedb_storage.py.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/tests/memory/test_unified_memory.py` around lines 191 - 199, Add a
test case in the unified memory storage suite to cover LIKE wildcard escaping
for scope matching: the current count assertions in the memory test only verify
slash and hyphen siblings, so extend the fixture around the storage.save and
storage.count checks with an underscore-sibling pair such as a scope using
/team_a and a same-length sibling like /teamZa. Update the assertions on
MemoryRecord scope filtering/count so they verify only the intended descendant
scope is included, thereby guarding the escaping behavior in lancedb_storage.py.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/crewai/src/crewai/memory/storage/lancedb_storage.py`:
- Around line 387-391: The `_scope_prefix_clause` helper only escapes quotes, so
the descendant `LIKE` branch still treats `_` and `%` as wildcards and can match
unrelated scopes. Update the clause-building logic in `lancedb_storage.py` to
escape LIKE metacharacters in `safe` before constructing the `scope LIKE ...`
predicate, and add an explicit `ESCAPE` clause if supported by
LanceDB/DataFusion SQL; otherwise change the descendant matching approach to
avoid wildcard interpretation entirely.

---

Nitpick comments:
In `@lib/crewai/tests/memory/test_unified_memory.py`:
- Around line 191-199: Add a test case in the unified memory storage suite to
cover LIKE wildcard escaping for scope matching: the current count assertions in
the memory test only verify slash and hyphen siblings, so extend the fixture
around the storage.save and storage.count checks with an underscore-sibling pair
such as a scope using /team_a and a same-length sibling like /teamZa. Update the
assertions on MemoryRecord scope filtering/count so they verify only the
intended descendant scope is included, thereby guarding the escaping behavior in
lancedb_storage.py.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dc04b228-e712-4aef-b19c-a2d2139ac0a5

📥 Commits

Reviewing files that changed from the base of the PR and between 2b90117 and f374353.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/memory/storage/lancedb_storage.py
  • lib/crewai/tests/memory/test_unified_memory.py

Comment on lines +387 to +391
prefix = scope_prefix.rstrip("/")
if not prefix.startswith("/"):
prefix = "/" + prefix
safe = prefix.replace("'", "''")
return f"(scope = '{safe}' OR scope LIKE '{safe}/%')"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unescaped LIKE wildcards (_, %) can reintroduce the very sibling leakage this PR fixes.

safe only escapes single quotes. In the LIKE branch, _ (single-char wildcard) and % (multi-char wildcard) are still interpreted by the matcher. A scope containing an underscore — common in team/scope names — will match unrelated siblings:

  • _scope_prefix_clause("/team_x")scope LIKE '/team_x/%', which also matches /teamAx/child, /team1x/child, etc. (the _ matches any single character).
  • % behaves even more broadly.

The exact-match branch is unaffected, but the descendant branch leaks across boundaries, defeating the fix. Escape LIKE metacharacters and add an ESCAPE clause.

🛡️ Proposed fix (escape LIKE metacharacters)
         prefix = scope_prefix.rstrip("/")
         if not prefix.startswith("/"):
             prefix = "/" + prefix
         safe = prefix.replace("'", "''")
-        return f"(scope = '{safe}' OR scope LIKE '{safe}/%')"
+        # Escape LIKE metacharacters so scopes containing '_' or '%' do not
+        # match unrelated siblings (e.g. '/team_x' must not match '/teamAx/..').
+        like_prefix = (
+            safe.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+        )
+        return f"(scope = '{safe}' OR scope LIKE '{like_prefix}/%' ESCAPE '\\')"

Please confirm LanceDB 0.29.2 (DataFusion SQL) honors the ESCAPE clause on LIKE; if not, the descendant match needs an alternative anchoring approach.

Does LanceDB / DataFusion SQL support the LIKE ESCAPE clause and treat underscore as a single-character wildcard?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/memory/storage/lancedb_storage.py` around lines 387 -
391, The `_scope_prefix_clause` helper only escapes quotes, so the descendant
`LIKE` branch still treats `_` and `%` as wildcards and can match unrelated
scopes. Update the clause-building logic in `lancedb_storage.py` to escape LIKE
metacharacters in `safe` before constructing the `scope LIKE ...` predicate, and
add an explicit `ESCAPE` clause if supported by LanceDB/DataFusion SQL;
otherwise change the descendant matching approach to avoid wildcard
interpretation entirely.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes scope-subtree filtering in LanceDBStorage so that scope operations match only the exact scope and its descendants under prefix + "/", preventing leakage into lexicographic sibling scopes (and avoiding unintended deletes/resets). It also adds a regression test capturing the sibling-scope scenario.

Changes:

  • Added a shared _scope_prefix_clause() helper and routed search, delete, _scan_rows, and reset scope filtering through it.
  • Added a unified-memory regression test ensuring /team does not include /team-b in counts and that reset("/team") does not delete /team-b.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
lib/crewai/src/crewai/memory/storage/lancedb_storage.py Centralizes and updates scope-subtree filtering to anchor on the / path boundary across reads and deletes/resets.
lib/crewai/tests/memory/test_unified_memory.py Adds a regression test covering sibling-scope isolation and reset() behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +387 to +391
prefix = scope_prefix.rstrip("/")
if not prefix.startswith("/"):
prefix = "/" + prefix
safe = prefix.replace("'", "''")
return f"(scope = '{safe}' OR scope LIKE '{safe}/%')"
Comment on lines +197 to +205
# 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"}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants