diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 57e96279f0..06c4c00964 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -1390,6 +1390,26 @@ class DeerFlowClient: ] } + def _memory_scope_kwargs(self, manager) -> dict[str, str]: + """Scope a memory management call to this client's named agent. + + A named client's graph stores and reads its memory in that agent's + bucket: ``MemoryMiddleware`` and the memory tools both pass + ``agent_name``. A management call that omits it targets the user's + default bucket instead, so a fact written through the client is never + injected back into the same client's conversations. + + A backend that accepts ``agent_name`` but ignores it would report + success while editing the user's default bucket, so a named client + refuses the call there instead of silently touching that data. The + Gateway answers the same request with 501. + """ + if self._agent_name is None: + return {} + if getattr(manager, "supports_agent_scoped_management", False) is not True: + raise NotImplementedError(f"agent-scoped memory management not supported by memory backend {type(manager).__name__}") + return {"agent_name": self._agent_name} + def get_memory(self) -> dict: """Get current memory data. @@ -1398,19 +1418,22 @@ class DeerFlowClient: """ from deerflow.agents.memory import get_memory_manager - return get_memory_manager().get_memory(user_id=get_effective_user_id()) + manager = get_memory_manager() + return manager.get_memory(user_id=get_effective_user_id(), **self._memory_scope_kwargs(manager)) def export_memory(self) -> dict: """Export current memory data for backup or transfer.""" from deerflow.agents.memory import get_memory_manager - return get_memory_manager().get_memory(user_id=get_effective_user_id()) + manager = get_memory_manager() + return manager.get_memory(user_id=get_effective_user_id(), **self._memory_scope_kwargs(manager)) def import_memory(self, memory_data: dict) -> dict: """Import and persist full memory data.""" from deerflow.agents.memory import get_memory_manager - return get_memory_manager().import_memory(memory_data, user_id=get_effective_user_id()) + manager = get_memory_manager() + return manager.import_memory(memory_data, user_id=get_effective_user_id(), **self._memory_scope_kwargs(manager)) def get_model(self, name: str) -> dict | None: """Get a specific model's configuration by name. @@ -1667,12 +1690,15 @@ class DeerFlowClient: manager = get_memory_manager() user_id = get_effective_user_id() + # Resolved before the fallback below: an unsupported agent scope must + # not be mistaken for "no reload concept" and retried as a user-wide read. + scope_kwargs = self._memory_scope_kwargs(manager) try: - return manager.reload_memory(user_id=user_id) + return manager.reload_memory(user_id=user_id, **scope_kwargs) except NotImplementedError: pass # no reload concept; fall back to current memory below try: - return manager.get_memory(user_id=user_id) + return manager.get_memory(user_id=user_id, **scope_kwargs) except NotImplementedError: raise NotImplementedError(f"reload_memory not supported by memory backend {type(manager).__name__}: implements neither reload_memory nor get_memory") from None @@ -1680,14 +1706,15 @@ class DeerFlowClient: """Clear all persisted memory data.""" from deerflow.agents.memory import get_memory_manager - return get_memory_manager().clear_memory(user_id=get_effective_user_id()) + manager = get_memory_manager() + return manager.clear_memory(user_id=get_effective_user_id(), **self._memory_scope_kwargs(manager)) def create_memory_fact(self, content: str, category: str = "context", confidence: float = 0.5) -> dict: """Create a single fact manually.""" from deerflow.agents.memory import get_memory_manager manager = get_memory_manager() - memory_data, fact_id = manager.create_fact(content=content, category=category, confidence=confidence, user_id=get_effective_user_id()) + memory_data, fact_id = manager.create_fact(content=content, category=category, confidence=confidence, user_id=get_effective_user_id(), **self._memory_scope_kwargs(manager)) if fact_id is None: raise ValueError("Fact was not stored because the configured memory.max_facts capacity policy evicted it") return memory_data @@ -1697,7 +1724,7 @@ class DeerFlowClient: from deerflow.agents.memory import get_memory_manager manager = get_memory_manager() - return manager.delete_fact(fact_id, user_id=get_effective_user_id()) + return manager.delete_fact(fact_id, user_id=get_effective_user_id(), **self._memory_scope_kwargs(manager)) def update_memory_fact( self, @@ -1716,6 +1743,7 @@ class DeerFlowClient: category=category, confidence=confidence, user_id=get_effective_user_id(), + **self._memory_scope_kwargs(manager), ) def get_memory_config(self) -> dict: diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 303bf2d321..7bd6da311b 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -3086,6 +3086,147 @@ class TestMemoryManagement: assert "data" in result +class TestNamedAgentMemoryManagement: + """A named client must manage the same bucket its agent reads and writes. + + ``MemoryMiddleware`` and the memory tools both pass ``agent_name``, so + leaving it out of the management calls wrote facts to the user's default + bucket while the agent looked in its own — the fact was stored but never + recalled (#8142). + """ + + @pytest.fixture + def named_client(self, client): + client._agent_name = "buyer" + return client + + def test_get_memory_reads_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.get_memory.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.get_memory() + assert mock_mgr.get_memory.call_args.kwargs["agent_name"] == "buyer" + + def test_reload_memory_reads_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.reload_memory.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.reload_memory() + assert mock_mgr.reload_memory.call_args.kwargs["agent_name"] == "buyer" + + def test_reload_memory_fallback_reads_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.reload_memory.side_effect = NotImplementedError("reload not supported") + mock_mgr.get_memory.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.reload_memory() + assert mock_mgr.get_memory.call_args.kwargs["agent_name"] == "buyer" + + def test_import_memory_writes_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.import_memory.return_value = {"version": "1.0", "facts": []} + imported = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.import_memory(imported) + assert mock_mgr.import_memory.call_args.kwargs["agent_name"] == "buyer" + + def test_clear_memory_clears_only_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.clear_memory.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.clear_memory() + assert mock_mgr.clear_memory.call_args.kwargs["agent_name"] == "buyer" + + def test_create_memory_fact_writes_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.create_fact.return_value = ({"version": "1.0", "facts": []}, "fact_new") + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.create_memory_fact("My preferred office paper size is A5.", category="preference") + assert mock_mgr.create_fact.call_args.kwargs["agent_name"] == "buyer" + + def test_delete_memory_fact_targets_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.delete_fact.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.delete_memory_fact("fact_123") + assert mock_mgr.delete_fact.call_args.kwargs["agent_name"] == "buyer" + + def test_update_memory_fact_targets_the_agent_bucket(self, named_client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.update_fact.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + named_client.update_memory_fact("fact_123", "Use A4") + assert mock_mgr.update_fact.call_args.kwargs["agent_name"] == "buyer" + + def test_backend_that_ignores_agent_scope_refuses_the_call(self, named_client): + """Noop-style backends accept ``agent_name`` and return normally while + ignoring it, so forwarding the scope alone would report success while + editing the user's default bucket. The capability flag must stop the + call before the backend sees it.""" + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = False + mock_mgr.get_memory.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + with pytest.raises(NotImplementedError): + named_client.get_memory() + with pytest.raises(NotImplementedError): + named_client.create_memory_fact("anything") + mock_mgr.get_memory.assert_not_called() + mock_mgr.create_fact.assert_not_called() + + def test_backend_without_the_flag_refuses_the_call(self, named_client): + """A backend that predates the capability flag never advertises it.""" + mock_mgr = MagicMock(spec=["get_memory", "create_fact"]) + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + with pytest.raises(NotImplementedError): + named_client.reload_memory() + mock_mgr.get_memory.assert_not_called() + + def test_unnamed_client_keeps_the_default_bucket(self, client): + mock_mgr = MagicMock() + mock_mgr.supports_agent_scoped_management = True + mock_mgr.get_memory.return_value = {"version": "1.0", "facts": []} + with patch("deerflow.agents.memory.get_memory_manager", return_value=mock_mgr): + client.get_memory() + assert "agent_name" not in mock_mgr.get_memory.call_args.kwargs + + +class TestNamedAgentMemoryScopeEndToEnd: + """Write through a named client, read back through the agent's own path.""" + + def test_fact_written_by_named_client_is_in_the_agents_bucket(self, mock_app_config, tmp_path): + from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem + from deerflow.runtime.user_context import reset_current_user, set_current_user + + manager = DeerMem(backend_config={"storage_path": str(tmp_path)}) + token = set_current_user(SimpleNamespace(id="alice")) + try: + with patch("deerflow.client.get_app_config", return_value=mock_app_config): + named = DeerFlowClient(agent_name="buyer") + with patch("deerflow.agents.memory.get_memory_manager", return_value=manager): + named.create_memory_fact("My preferred office paper size is A5.", category="preference") + + # The agent's own path: MemoryMiddleware injects through + # get_context(agent_name=...), so a fact it cannot see there is + # never recalled. + agent_context = manager.get_context("alice", agent_name="buyer") + default_bucket = manager.get_memory(user_id="alice") + + assert "A5" in agent_context + assert default_bucket["facts"] == [] + finally: + reset_current_user(token) + manager.close() + + # --------------------------------------------------------------------------- # Uploads # --------------------------------------------------------------------------- diff --git a/docs/guide/memory.md b/docs/guide/memory.md index a24d4ef0b9..d9805c697c 100644 --- a/docs/guide/memory.md +++ b/docs/guide/memory.md @@ -146,6 +146,16 @@ Memory injection follows the configured operation mode. In `middleware` mode, De An individual Custom Agent can opt out of memory without changing the global setting. Add `memory_enabled: false` to that agent's `users/{user_id}/agents/{name}/config.yaml`. The agent still receives the current-date reminder, but DeerFlow does not inject recalled memory, queue passive or summarization-driven memory updates (including manual `/compact`), expose memory tools, or add memory-tool instructions for that agent. If an existing agent is switched off, its previously injected memory block is removed from checkpoint state before the next model call while its date reminder and conversation remain. Omitting the field (or setting it to `true`) preserves the existing global `memory` behavior. +`DeerFlowClient(agent_name=...)` manages memory in the same bucket the agent +uses in conversations. An unnamed client uses the default bucket. Named clients +raise `NotImplementedError` when the backend lacks agent-scoped management. +The Gateway returns HTTP 501 for the same request. + +This also applies to the `noop` backend. On a named client, `get_memory()`, +`export_memory()`, `reload_memory()`, `get_memory_status()`, `clear_memory()`, +and `import_memory()` now raise `NotImplementedError`. These methods previously +returned empty memory data with that backend. + Single-fact repository operations are genuinely incremental: an upsert/delete reads, journals, writes, and re-indexes only the addressed fact files, and returns an explicit incomplete delta rather than a cache-dependent fake full document. Summary change sets merge the supplied `user`/`history` child keys over the persisted sections so a partial update cannot erase omitted siblings; full imports normalize both sections to the complete compatibility schema before applying replacement values. Imports reject malformed fact lists or blank/non-text content with HTTP 400 before changing stored memory; recoverable legacy metadata still receives defaults. An explicit empty fact list remains an intentional clear. Manager/API compatibility methods materialize a fresh full document only when their public response contract requires one. Fact-level point operations use separate expected user-memory and fact revisions and may explicitly rebase when every addressed fact precondition still holds. Snapshot-derived operations such as scoped clear, capped create, consolidation, and trimming never replay stale delete/trim sets: a manifest conflict reloads the complete document and recomputes the operation, with a bounded retry. Fact paths use the first two hexadecimal characters of `SHA-256(fact_id)` so generated `fact_*` IDs distribute across shards. The cache token combines the shared JSON's nanosecond mtime, size, and persisted revision; this prevents coarse-mtime same-size writes from returning stale data without scanning fact files. Direct out-of-band Markdown edits require an explicit reload. Storage-specific conflicts and corruption are translated at the MemoryManager boundary; the Gateway returns conflict as HTTP 409 and a stable, non-sensitive corruption error as HTTP 500. Full-document `save()` remains a compatibility API and computes a diff before writing; malformed or missing `facts` can no longer silently erase an agent's Markdown files. Legacy migration preserves non-empty `user`/`history` before deleting an agent `memory.json`; conflicting summaries keep the legacy file and fail loudly instead of choosing a winner. DeerMem backup imports restore saved fact contents even after those facts have