Skip to main content

CrewAI deep recall can return memories outside root_scope: keep each crew's recall inside its own scope

In CrewAI 1.15.33, Memory.recall(depth="deep") searches every scope the LLM suggests, even outside the caller's root_scope. Here's how to check, a settings-only mitigation, and a small storage wrapper that keeps reads inside one root.

Updated 14 min readMarkdown

Disclosure: I work on MemorySync (I'm Mohammed, the founder). I drafted this article with AI assistance and checked it against the GitHub issue and pull request, the CrewAI docs, the CrewAI 1.15.33 source and the MemorySync docs listed under Sources on Oct 29, 2026. Code status: I ran the reproduction and both workarounds offline against crewai 1.15.33 and its default LanceDB backend, with a stubbed LLM and a stubbed embedder (no model calls, no crew kickoff). The Crew(...) wiring snippet and the MemorySync snippet are adapted from the docs and were not run.

CrewAI issue #8767 (Deep recall can return memories outside root_scope via LLM-suggested scopes) (opened Oct 29, 2026) describes a scope leak in the unified Memory class. With depth="deep", which is the default, a long query goes to the LLM for analysis, and the LLM can suggest scopes to search. In 1.15.33 those suggestions are searched as-is. In the issue's words, "an LLM suggestion such as /crew/b/inner can therefore make Memory(root_scope="/crew/a").recall(...) return Crew B's records. Suggesting / searches the whole store."

This matters if more than one crew, customer or agent shares one memory store and you rely on root_scope or memory.scope(...) to keep them apart. By default CrewAI keeps one LanceDB store per project ($CREWAI_STORAGE_DIR/memory, or a per-project folder in your user data directory in the 1.15.33 source; the docs say ./.crewai/memory), so crews in the same project share one unless you pass storage=.

MemorySync doesn't fix this bug. The leak happens in CrewAI's recall flow before any storage backend is called. The fix is on your side until the pull request ships: stop the LLM from choosing scopes, or clamp every read to one root. This page shows both, plus a naming rule for a related prefix bug. CrewAI behaviour comes from the issue, the CrewAI memory docs and the 1.15.33 source.

Skip the memory plumbing around this setup. Once your recall is clamped, the MemorySync CrewAI guide shows how to point Memory(storage=...) at MemorySync so crew memory survives redeploys and can be kept per user_id. MemorySync doesn't replace the fix below. When you're ready, see MemorySync. Founding offer: use code FOUNDING30 for 30% off Starter for your first 3 months (Settings → Billing).

Fix status (Nov 2, 2026)

  • Issue #8767: open, opened Oct 29, 2026, no comments yet.
  • Pull request #8768, "fix(memory): keep deep recall inside requested scope": open, not merged. It drops any suggested scope that isn't the caller's scope or a /-delimited child of it, falls back to the caller's scope when nothing is left, and filters returned records against the searched scope.
  • Latest release, 1.15.34 (Nov 2, 2026): recall_flow.py is unchanged from 1.15.33, where filter_and_chunk() still takes every non-empty suggested_scopes entry as a search scope. I reproduced the leak against 1.15.33 (results below). Treat it as unfixed in released versions until a release includes the PR.

Step 1: Check whether you're exposed

From the 1.15.33 source and the docs, you can hit this when all of these are true:

  1. Several scopes share one store. A crew with memory=True gets a Memory whose root_scope is /crew/<crew name> (crew.py at 1.15.33). Several crews, or several customers under /customer/..., in one storage path is the shared-store case.
  2. Recall is deep. recall() defaults to depth="deep". Agents recall automatically before each task with the task description as the query and no depth argument (1.15.33 source), so automatic crew recall is deep.
  3. The query is long enough to reach the LLM. Per the docs, "Queries shorter than query_analysis_threshold (default 200 characters) skip the LLM query analysis entirely, even in deep mode." Task descriptions are often longer than that.

The LLM is shown the scopes under the caller's root when it analyses the query (1.15.33 source), so an out-of-root suggestion is the model making up or widening a path, for example /. Nothing in 1.15.33 stops that suggestion from being searched.

Here is what I got offline on 1.15.33. The store held /crew/a/inner, /crew/b/inner and /crew/a-neighbor; the stub LLM returned the suggestion in the middle column (the setup from the issue's reproduction):

Setup, all with root_scope="/crew/a" LLM suggests Scopes returned
Plain Memory, depth="deep", analysis on /crew/b/inner /crew/b/inner
Plain Memory, depth="deep", analysis on / /crew/a-neighbor, /crew/a/inner, /crew/b/inner
Plain Memory, depth="deep", analysis on /crew/a-neighbor /crew/a-neighbor
Plain Memory, depth="shallow" (no LLM) /crew/a-neighbor, /crew/a/inner
query_analysis_threshold=1_000_000 (Step 2) each of the three /crew/a/inner (LLM not called)
RootScopedStorage wrapper (Step 3), deep and shallow each of the three /crew/a/inner

The shallow row is a separate, older problem: the LanceDB storage filters scopes with a plain string prefix (scope LIKE '/crew/a%'), so /crew/a also matches /crew/a-neighbor. That was reported in #7543 (closed as not planned) and PR #7544 was closed without merging. Step 4 covers it.

Step 2: Settings-only mitigation

If you can live without LLM query analysis on recall, you can stop the LLM from choosing scopes with configuration only.

For your own calls, use shallow recall, which the docs describe as "Direct vector search with composite scoring" with "no LLM calls":

matches = memory.recall("what did we decide about pricing?", depth="shallow")

For automatic crew recall you can't pass depth, so raise the analysis threshold instead. Build the Memory yourself and hand it to the crew:

from crewai import Crew, Memory

memory = Memory(
    root_scope="/crew/research",       # what memory=True sets for a crew named "Research"
    query_analysis_threshold=1_000_000,  # no query is this long, so the LLM never picks scopes
    # embedder=...  # memory=True passed the crew's embedder for you; set it here yourself
)

crew = Crew(agents=[...], tasks=[...], memory=memory)

Two things to know. When you pass your own Memory, the crew doesn't set root_scope for you (1.15.33 crew.py), so set it. And this gives up the LLM step that the docs say turns long queries into "targeted sub-queries"; the raw query is embedded instead. With analysis skipped, the 1.15.33 flow searches the child scopes that storage lists under your root, or the root itself if it has none. It doesn't fix the look-alike prefix match from Step 1: offline, with a record stored directly at /crew/a, this setup returned it plus the /crew/a-neighbor record. That's why Step 3 exists.

Step 3: Clamp every read to one root at the storage layer

Memory(storage=...) accepts any object that behaves like CrewAI's StorageBackend (the docs list custom backends; the 1.15.33 unified_memory.py passes the object through). This wrapper replaces any out-of-root scope with the root, drops results whose scope isn't the searched scope or a /-delimited child of it, and passes everything else to the real backend:

from crewai.memory.utils import normalize_scope_path


def within(path: str | None, root: str) -> bool:
    """True if `path` is `root` or a slash-delimited child of it."""
    path = normalize_scope_path(path or "/")
    root = normalize_scope_path(root)
    return root == "/" or path == root or path.startswith(root + "/")


class RootScopedStorage:
    """Wrap a CrewAI storage backend so reads never leave one root scope."""

    def __init__(self, inner, root: str):
        self._inner = inner
        self._root = normalize_scope_path(root)

    def _clamp(self, scope_prefix: str | None) -> str:
        # Out-of-root scopes (another crew, "/", a look-alike sibling)
        # are replaced by the root itself.
        if scope_prefix is None or not within(scope_prefix, self._root):
            return self._root
        return normalize_scope_path(scope_prefix)

    def search(self, query_embedding, scope_prefix=None, *args, **kwargs):
        scope = self._clamp(scope_prefix)
        raw = self._inner.search(query_embedding, scope, *args, **kwargs)
        # The default LanceDB filter is a plain string prefix, so "/crew/a"
        # also matches "/crew/a-neighbor". Drop those here.
        return [(r, s) for r, s in raw if within(r.scope, scope)]

    async def asearch(self, query_embedding, scope_prefix=None, *args, **kwargs):
        scope = self._clamp(scope_prefix)
        raw = await self._inner.asearch(query_embedding, scope, *args, **kwargs)
        return [(r, s) for r, s in raw if within(r.scope, scope)]

    def list_scopes(self, parent: str = "/") -> list[str]:
        return [s for s in self._inner.list_scopes(self._clamp(parent))
                if within(s, self._root)]

    def __getattr__(self, name):
        # Writes, deletes and everything else go straight to the real backend.
        return getattr(self._inner, name)

Wire it in with the same root you give Memory:

from crewai import Crew, Memory
from crewai.memory.storage.lancedb_storage import LanceDBStorage
from root_scoped_storage import RootScopedStorage  # the code above

ROOT = "/crew/research"

memory = Memory(
    storage=RootScopedStorage(LanceDBStorage(), ROOT),  # LanceDBStorage() is the default store
    root_scope=ROOT,
    # embedder=..., llm=...  # same settings your crew used before
)

crew = Crew(agents=[...], tasks=[...], memory=memory)

What it does and doesn't do:

  • In my offline run it kept deep and shallow recall inside /crew/a for all three bad suggestions (table above), writes still landed under the root (/notes was stored as /crew/a/notes), and list_scopes() only listed in-root scopes.
  • It enforces one boundary per Memory. It doesn't know about a narrower scope= you pass to one recall() call, or about memory.scope("/agent/researcher") views, so a deep recall from a view can still reach a sibling branch inside the same root. The PR checks the explicit scope too.
  • It only guards reads. forget(), reset() and writes go straight to the backend.
  • One crew or customer per Memory. If you serve many customers, build one wrapped Memory per customer root.

Step 4: Don't let one root be a string prefix of another

Because the LanceDB filter is a plain prefix, /crew/research also matches /crew/research-2 in every recall that hasn't been through the Step 3 filter. CrewAI builds the crew root from the crew name with sanitize_scope_name (1.15.33 source); offline, "Research", "Research 2" and "Research Crew" became research, research-2 and research-crew. Those three crews in one store would read each other's memories on shallow recall.

Either keep the Step 3 wrapper on, or choose names whose sanitised forms don't start with each other, such as research-a and research-b, or give each crew its own storage= path. Same rule for customer roots: /customer/acme and /customer/acme-corp collide; IDs of a fixed length don't.

Step 5: Check it with the issue's reproduction

Adapted from the issue and the PR's regression test. Run it against a throwaway storage path, never your real store. It uses a stub LLM and embedder, so it makes no model calls:

import tempfile
from pathlib import Path
from unittest.mock import MagicMock

from crewai.memory.analyze import QueryAnalysis
from crewai.memory.storage.lancedb_storage import LanceDBStorage
from crewai.memory.unified_memory import Memory
from root_scoped_storage import RootScopedStorage  # the Step 3 code, saved as root_scoped_storage.py

embedder = MagicMock(side_effect=lambda texts: [[0.1] * 4 for _ in texts])
path = str(Path(tempfile.mkdtemp()) / "memory")

seed = Memory(storage=path, llm=MagicMock(), embedder=embedder)
seed.remember("Allowed record", scope="/crew/a/inner", categories=["test"], importance=0.5)
seed.remember("Other record", scope="/crew/b/inner", categories=["test"], importance=0.5)
seed.drain_writes()

llm = MagicMock()
llm.supports_function_calling.return_value = False
llm.call.return_value = QueryAnalysis(suggested_scopes=["/crew/b/inner"], recall_queries=["record"])

memory = Memory(
    storage=RootScopedStorage(LanceDBStorage(path=path), "/crew/a"),  # remove the wrapper to see the leak
    llm=llm, embedder=embedder, root_scope="/crew/a",
    query_analysis_threshold=0, exploration_budget=0,
)
print([m.record.scope for m in memory.recall("record", depth="deep")])  # expect ['/crew/a/inner']

On 1.15.33 without the wrapper, the same call printed ['/crew/b/inner'] for me.

Step 6: Watch the fix

Subscribe to #8767 and PR #8768. Once a release includes the PR, re-run Step 5 without the wrapper before you drop it. The PR's own notes say the backend prefix filter is a separate problem: LanceDB "applies its current broad prefix predicate before the search result limit, so a large number of higher-ranked siblings can still leave fewer in-scope results than requested." So keep the Step 4 naming rule after the fix too.

Skip the plumbing with MemorySync

Disclosure: I build MemorySync.

This doesn't change the fix above. The MemorySync CrewAI guide says MemorySyncStorage implements CrewAI's StorageBackend protocol, so Memory(storage=MemorySyncStorage(user_id=...)) runs the crew's save and recall loop against MemorySync instead of local LanceDB files, and records "survive redeploys". It also says "scope prefixes, category filters, metadata filters and scores behave exactly like the built-in LanceDB backend." CrewAI's recall flow still picks the scopes, so the deep-recall bug is the same with this backend. Keep Step 2 or Step 3.

What the guide adds that is relevant here is the owner id. It describes user_id as the memory owner, with one id per crew for a shared knowledge base or one id per end user for per-customer memory. A separate store per crew is the other way around this bug, and with this backend that's a different user_id rather than a LanceDB directory per crew. I haven't tested deep recall across two user_ids. The guide also says MemorySyncStorage implements the full protocol (search, scopes and the async twins included), so the Step 3 wrapper should sit on top of it the same way, but I haven't run that either.

From the guide:

Unverified (not run):

pip install crewai-memorysync
from crewai import Agent, Crew, Task
from crewai.memory import Memory
from crewai_memorysync import MemorySyncStorage

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    memory=Memory(
        storage=MemorySyncStorage(user_id="support-crew"),
    ),
)

crew.kickoff()

Snippet adapted from the MemorySync CrewAI guide; I haven't run it for this page. The guide lists crewai-memorysync 1.1.0, which requires crewai 1.0+ and Python 3.10+, with MEMORYSYNC_API_KEY set in the environment. Setup from scratch is in my earlier tutorial, Persistent memory for CrewAI 1.x.

Try it: MemorySync is optional here: the steps above work without it. If you want the memory layer from the snippet, see MemorySync, create an API key in the dashboard and drop the snippet into your agent.

Troubleshooting

  • An agent's context mentions another crew's or customer's facts. Check the three conditions in Step 1. If they hold, apply Step 2 or Step 3, then run Step 5.
  • Recall leaks only on long task descriptions. That fits the docs: queries under query_analysis_threshold (default 200 characters) skip the LLM step, so only long ones get suggested scopes.
  • Shallow recall still returns a similarly named sibling. That's the prefix match in Step 1 (#7543), not this bug. Use the Step 3 wrapper or rename roots (Step 4).
  • After passing my own Memory to the crew, everything lands at / or at the top level. You dropped the root_scope that memory=True set. Set root_scope yourself (Step 2).
  • Recall returns fewer results than limit. Expected with the wrapper when the backend returned out-of-root rows that were filtered out. The PR notes the same limit with its fix.
  • Scoped deletes miss records. That's a different bug, covered in the scope leading-slash page.

Common questions

Does root_scope keep crews apart in CrewAI memory? For saves, yes: explicit scopes are joined under it. For deep recall in 1.15.33, no: an LLM-suggested scope outside the root is searched as-is (#8767). Shallow recall stays under the root string but can match look-alike siblings.

Is this fixed in the latest CrewAI? Not as of Nov 2, 2026 (1.15.34). Issue #8767 and PR #8768 are open, and 1.15.33 still searches suggested scopes unchecked. Check the issue before relying on a newer release.

Is depth="shallow" enough? It stops the LLM from choosing scopes, so it avoids #8767. It still uses the prefix filter, so /crew/a matches /crew/a-neighbor. Pair it with Step 4 or Step 3.

Do I lose anything with the storage wrapper? Deep recall keeps working, LLM analysis included. You lose results that the backend returned from outside the root, which you didn't want anyway, so a recall can come back with fewer than limit matches.

Sources

Try it

For an overview of MemorySync, see the MemorySync homepage.

Next step: read the MemorySync CrewAI guide, then see MemorySync. Founding offer: use code FOUNDING30 for 30% off Starter for your first 3 months (Settings → Billing).

About the author

  • Building MemorySync (memorysync.io) — persistent memory infrastructure for AI agents, Cursor, and Claude Code.

No comments yet. Be the first to comment.