From b6a494167c276bacc71323fbf8c26c091978c420 Mon Sep 17 00:00:00 2001 From: Sampoorn Nagpal Date: Sat, 5 Sep 2026 18:57:06 +0530 Subject: [PATCH 1/2] =?UTF-8?q?fix(memory):=20defer=20resources=20for=20di?= =?UTF-8?q?sabled=20managers=20=F0=9F=A4=96=F0=9F=A4=96=F0=9F=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sampoorn Nagpal --- .../nooa-memory/src/nooa_memory/README.md | 5 ++ .../nooa-memory/src/nooa_memory/manager.py | 60 +++++++++++++------ .../tests/memory/test_memory_manager.py | 33 ++++++++++ 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/packages/nooa-memory/src/nooa_memory/README.md b/packages/nooa-memory/src/nooa_memory/README.md index 74021e1a8..3cebf9239 100644 --- a/packages/nooa-memory/src/nooa_memory/README.md +++ b/packages/nooa-memory/src/nooa_memory/README.md @@ -35,6 +35,11 @@ accreted over time**, modeled on how the brain uses memory. When `enabled=False` (or not installed), the agent is byte-for-byte unchanged — the *additive guarantee* (regression-tested). +A disabled installation does not construct an embedder or open its database. +Explicitly accessing the manager's store or calling its operations initializes +the resources on demand; agent-facing memory tools remain disabled. Uninstalling +an unused disabled manager does not create resources either. + --- ## Quick start diff --git a/packages/nooa-memory/src/nooa_memory/manager.py b/packages/nooa-memory/src/nooa_memory/manager.py index 4573a18e8..d64a544c8 100644 --- a/packages/nooa-memory/src/nooa_memory/manager.py +++ b/packages/nooa-memory/src/nooa_memory/manager.py @@ -23,6 +23,7 @@ import threading import time from collections.abc import Callable +from functools import cached_property from typing import Any from nooa.agent import Agent @@ -141,24 +142,7 @@ def __init__( # the TUI pass their stable per-agent key; "" writes to the shared # namespace); the honest library default is the agent's class name. self.owner = self.config.owner if self.config.owner is not None else type(agent).__name__ - self.embedder = embedder or get_embedder(self.config.embedding) - self.store = self._make_store(agent) - self.retrieval = RetrievalEngine( - self.store, - self.embedder, - self.config.retrieval, - access_log_cap=self.config.observability.access_log_cap, - ) - # Engines consolidate at ROLE scope: instance scope would fragment - # knowledge per session (and reflection must fold across instances). - self.reflection_engine = ReflectionEngine( - self.store, - self.embedder, - self.config.reflection, - self.config.forget, - owner=self.role, - ) - self.forgetting = ForgettingEngine(self.store, self.config.forget, owner=self.role) + self._provided_embedder = embedder self._reasoner = reasoner self._reconciler = reconciler self.stats = MemoryStats() @@ -174,8 +158,43 @@ def __init__( self._last_query_hash: int | None = None self._primed = False + if self.config.enabled: + # Keep enabled installs eager, including backend validation. + _ = self.retrieval, self.reflection_engine, self.forgetting self._install_hooks() + @cached_property + def embedder(self) -> Embedder: + return self._provided_embedder or get_embedder(self.config.embedding) + + @cached_property + def store(self) -> MemoryStore: + return self._make_store(self.agent) + + @cached_property + def retrieval(self) -> RetrievalEngine: + return RetrievalEngine( + self.store, + self.embedder, + self.config.retrieval, + access_log_cap=self.config.observability.access_log_cap, + ) + + @cached_property + def reflection_engine(self) -> ReflectionEngine: + # Engines consolidate at ROLE scope, folding knowledge across instances. + return ReflectionEngine( + self.store, + self.embedder, + self.config.reflection, + self.config.forget, + owner=self.role, + ) + + @cached_property + def forgetting(self) -> ForgettingEngine: + return ForgettingEngine(self.store, self.config.forget, owner=self.role) + def _make_store(self, agent: Agent) -> MemoryStore: path = self.config.path or self._default_path(agent) return MemoryStore(path, vector_config=self.config.vector, embedding_dim=self.embedder.dim) @@ -244,7 +263,10 @@ def uninstall(self) -> None: if not task.done(): task.cancel() self._pending.clear() - self.store.close() + # Uninstalling an unused disabled manager must not initialize its store. + store = self.__dict__.get("store") + if store is not None: + store.close() if getattr(self.agent, "_memory", None) is self: del self.agent._memory # type: ignore[attr-defined] diff --git a/packages/nooa-memory/tests/memory/test_memory_manager.py b/packages/nooa-memory/tests/memory/test_memory_manager.py index e91c54add..44f302852 100644 --- a/packages/nooa-memory/tests/memory/test_memory_manager.py +++ b/packages/nooa-memory/tests/memory/test_memory_manager.py @@ -69,6 +69,39 @@ def test_disabled_install_is_inert(agent): assert len(agent.event_manager._middleware["agent_call"]) == 0 +def test_disabled_install_does_not_create_database(agent, tmp_path): + path = tmp_path / "unused" / "memory.sqlite" + mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=str(path))) + try: + assert not path.parent.exists() + finally: + mgr.uninstall() + assert not path.parent.exists() + + +def test_disabled_install_does_not_construct_embedder(agent, monkeypatch): + def unexpected_embedder(config): + pytest.fail("disabled installation constructed an embedder") + + monkeypatch.setattr("nooa_memory.manager.get_embedder", unexpected_embedder) + mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=":memory:")) + mgr.uninstall() + + +def test_disabled_manager_explicit_use_initializes_resources_once(agent, tmp_path): + path = tmp_path / "memory.sqlite" + mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=str(path))) + try: + mid = mgr.remember("explicit manager operation") + assert path.exists() + store = mgr.store + assert mgr.store is store + assert store.get(mid).content == "explicit manager operation" + assert mgr._unsubs == [] + finally: + mgr.uninstall() + + def test_install_replaces_existing_manager_without_leaking_hooks(agent): first = _install(agent) second = _install(agent) From b5347af5eebdf10ea11b341d69a4d6297f0e587b Mon Sep 17 00:00:00 2001 From: Sampoorn Nagpal Date: Sun, 6 Sep 2026 21:53:39 +0530 Subject: [PATCH 2/2] =?UTF-8?q?docs(memory):=20explain=20lazy=20resource?= =?UTF-8?q?=20lifecycle=20contracts=20=F0=9F=A4=96=F0=9F=A4=96=F0=9F=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sampoorn Nagpal --- packages/nooa-memory/src/nooa_memory/manager.py | 6 ++++++ packages/nooa-memory/tests/memory/test_memory_manager.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/packages/nooa-memory/src/nooa_memory/manager.py b/packages/nooa-memory/src/nooa_memory/manager.py index d64a544c8..2b40842fd 100644 --- a/packages/nooa-memory/src/nooa_memory/manager.py +++ b/packages/nooa-memory/src/nooa_memory/manager.py @@ -165,14 +165,17 @@ def __init__( @cached_property def embedder(self) -> Embedder: + """Reuse the supplied embedder or construct the configured backend on first use.""" return self._provided_embedder or get_embedder(self.config.embedding) @cached_property def store(self) -> MemoryStore: + """Open the configured memory database once, only when storage is needed.""" return self._make_store(self.agent) @cached_property def retrieval(self) -> RetrievalEngine: + """Build the recall engine against this manager's shared store and embedder.""" return RetrievalEngine( self.store, self.embedder, @@ -182,6 +185,7 @@ def retrieval(self) -> RetrievalEngine: @cached_property def reflection_engine(self) -> ReflectionEngine: + """Initialize role-scoped reflection lazily, sharing the manager's resources.""" # Engines consolidate at ROLE scope, folding knowledge across instances. return ReflectionEngine( self.store, @@ -193,9 +197,11 @@ def reflection_engine(self) -> ReflectionEngine: @cached_property def forgetting(self) -> ForgettingEngine: + """Initialize role-scoped forgetting against the cached memory store.""" return ForgettingEngine(self.store, self.config.forget, owner=self.role) def _make_store(self, agent: Agent) -> MemoryStore: + """Open explicit or agent-default storage using the configured embedding dimension.""" path = self.config.path or self._default_path(agent) return MemoryStore(path, vector_config=self.config.vector, embedding_dim=self.embedder.dim) diff --git a/packages/nooa-memory/tests/memory/test_memory_manager.py b/packages/nooa-memory/tests/memory/test_memory_manager.py index 44f302852..3a93ff408 100644 --- a/packages/nooa-memory/tests/memory/test_memory_manager.py +++ b/packages/nooa-memory/tests/memory/test_memory_manager.py @@ -64,12 +64,14 @@ def test_uninstall_removes_all_hooks(agent): def test_disabled_install_is_inert(agent): + """Disabled installation does not register hooks or enable agent-facing memory tools.""" mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=":memory:")) assert mgr._unsubs == [] assert len(agent.event_manager._middleware["agent_call"]) == 0 def test_disabled_install_does_not_create_database(agent, tmp_path): + """Installing and uninstalling an unused disabled manager leaves the filesystem alone.""" path = tmp_path / "unused" / "memory.sqlite" mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=str(path))) try: @@ -80,7 +82,10 @@ def test_disabled_install_does_not_create_database(agent, tmp_path): def test_disabled_install_does_not_construct_embedder(agent, monkeypatch): + """An unused disabled manager never needs to construct an embedding backend.""" + def unexpected_embedder(config): + """Fail immediately if disabled installation tries to initialize embedding resources.""" pytest.fail("disabled installation constructed an embedder") monkeypatch.setattr("nooa_memory.manager.get_embedder", unexpected_embedder) @@ -89,6 +94,7 @@ def unexpected_embedder(config): def test_disabled_manager_explicit_use_initializes_resources_once(agent, tmp_path): + """Direct manager operations lazily reuse resources without enabling agent-facing hooks.""" path = tmp_path / "memory.sqlite" mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=str(path))) try: @@ -103,6 +109,7 @@ def test_disabled_manager_explicit_use_initializes_resources_once(agent, tmp_pat def test_install_replaces_existing_manager_without_leaking_hooks(agent): + """Replacing a manager unregisters the previous hooks before installing new ones.""" first = _install(agent) second = _install(agent)