From 40c9b562c08e0dd0e595a90c3c11db66f94a06f9 Mon Sep 17 00:00:00 2001 From: tandede <1090179959@qq.com> Date: Thu, 20 Aug 2026 19:09:28 +0800 Subject: [PATCH] fix(memory): preserve reusable scope configs --- 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 1cd09d4762..de990e0fd0 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 @@ def _accept_memory(cls, data: Any, handler: Any) -> MemoryScope: 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 @@ def _accept_memory(cls, data: Any, handler: Any) -> MemorySlice: 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 8a3d52e7a4..65363efada 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