From 6eb48335981e4e4dc2f3ee8bb7ea741a7f7e3cd1 Mon Sep 17 00:00:00 2001 From: 2SinceJuly <2929362966@qq.com> Date: Thu, 12 Nov 2026 09:21:28 +0800 Subject: [PATCH] fix(client): scope memory management to the named agent's bucket (#8146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client): scope memory management to the named agent's bucket A named DeerFlowClient stored and read its memory in that agent's bucket: MemoryMiddleware and the memory tools both pass agent_name. The management methods on the client did not, so create_memory_fact wrote the fact into the user's default bucket while the agent's injected context came from its own bucket — the fact was persisted but never recalled by the same client. Pass the client's agent_name through get_memory, export_memory, import_memory, reload_memory, clear_memory and the single-fact CRUD calls. An unnamed client keeps the default bucket, and a backend that ignores the scoping keyword keeps raising its own unsupported-operation error. Fixes #8142 * fix(client): refuse named memory calls a backend cannot scope DeerFlowClient forwarded the agent name to every memory management call, but a backend that declares no agent-scoped storage ignores the keyword and answers normally. A named client then reported success while the call read, cleared or exported the user's default bucket, the same silent wrong-bucket behaviour #8142 is about. Check the capability flag before forwarding the scope and raise an unsupported-operation error, as the Gateway already does with 501. Resolve the scope in reload_memory before the get_memory fallback so an unsupported scope is not mistaken for a backend without reload support and retried as a user-wide read. * chore: drop the CHANGELOG entry The changelog is generated at release time, and the hand-written entry points at #8146 without a link definition, which breaks tests/test_changelog_reference_links.py. Removing it keeps the file released- generated, per the reviewer's request. * docs(memory): explain scoped noop client behavior --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: Willem Jiang --- backend/packages/harness/deerflow/client.py | 44 ++++-- backend/tests/test_client.py | 141 ++++++++++++++++++++ docs/guide/memory.md | 10 ++ 3 files changed, 187 insertions(+), 8 deletions(-) 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