From 98799a3b094e66293662d08fc45a6d62d2909c34 Mon Sep 17 00:00:00 2001 From: Zhewen Tan <1090179959@qq.com> Date: Wed, 2 Sep 2026 22:05:30 +0800 Subject: [PATCH] fix(memory): preserve reusable scope configs (#7068) Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com> --- lib/crewai/src/crewai/memory/memory_scope.py | 3 ++ .../tests/memory/test_unified_memory.py | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lib/crewai/src/crewai/memory/memory_scope.py b/lib/crewai/src/crewai/memory/memory_scope.py index 1cd09d476..de990e0fd 100644 --- a/lib/crewai/src/crewai/memory/memory_scope.py +++ b/lib/crewai/src/crewai/memory/memory_scope.py @@ -26,6 +26,7 @@ def _ensure_memory_kind(value: Any) -> Any: Pass-through for non-dict values (instances, ``bool``, ``None``). """ if isinstance(value, dict) and "memory_kind" not in value: + value = dict(value) if "scopes" in value: value["memory_kind"] = "slice" elif "root_path" in value: @@ -55,6 +56,7 @@ class MemoryScope(BaseModel): return data if not isinstance(data, dict): raise ValueError(f"Expected dict or MemoryScope, got {type(data).__name__}") + data = dict(data) memory = data.pop("memory", None) instance: MemoryScope = handler(data) if memory is not None: @@ -245,6 +247,7 @@ class MemorySlice(BaseModel): return data if not isinstance(data, dict): raise ValueError(f"Expected dict or MemorySlice, got {type(data).__name__}") + data = dict(data) memory = data.pop("memory", None) data["scopes"] = [s.rstrip("/") or "/" for s in data.get("scopes", [])] instance: MemorySlice = handler(data) diff --git a/lib/crewai/tests/memory/test_unified_memory.py b/lib/crewai/tests/memory/test_unified_memory.py index 8a3d52e7a..65363efad 100644 --- a/lib/crewai/tests/memory/test_unified_memory.py +++ b/lib/crewai/tests/memory/test_unified_memory.py @@ -240,6 +240,50 @@ def test_memory_scope_slice(tmp_path: Path, mock_embedder: MagicMock) -> None: assert "/a" in sl.scopes and "/b" in sl.scopes +def test_memory_scope_config_can_be_reused() -> None: + """Constructing a scope must not remove the memory from caller-owned config.""" + from crewai.memory.memory_scope import MemoryScope + + memory = MagicMock() + config = {"memory": memory, "root_path": "/agent/1"} + + first = MemoryScope.model_validate(config) + second = MemoryScope.model_validate(config) + + assert config == {"memory": memory, "root_path": "/agent/1"} + assert first._require_memory() is memory + assert second._require_memory() is memory + + +def test_memory_slice_config_can_be_reused_without_normalizing_it_in_place() -> None: + """Constructing a slice must preserve caller-owned dependencies and paths.""" + from crewai.memory.memory_scope import MemorySlice + + memory = MagicMock() + config = {"memory": memory, "scopes": ["/team/", "/"]} + + first = MemorySlice.model_validate(config) + second = MemorySlice.model_validate(config) + + assert config == {"memory": memory, "scopes": ["/team/", "/"]} + assert first.scopes == ["/team", "/"] + assert second.scopes == ["/team", "/"] + assert first._require_memory() is memory + assert second._require_memory() is memory + + +def test_memory_kind_inference_preserves_input() -> None: + """Inferring a legacy config's discriminator must not mutate that config.""" + from crewai.memory.memory_scope import _ensure_memory_kind + + config = {"root_path": "/agent/1"} + + normalized = _ensure_memory_kind(config) + + assert config == {"root_path": "/agent/1"} + assert normalized == {"root_path": "/agent/1", "memory_kind": "scope"} + + def test_memory_list_scopes_info_tree(tmp_path: Path, mock_embedder: MagicMock) -> None: from crewai.memory.unified_memory import Memory