diff --git a/backend/app/gateway/routers/memory.py b/backend/app/gateway/routers/memory.py index 85fed66dca..48454a7a7b 100644 --- a/backend/app/gateway/routers/memory.py +++ b/backend/app/gateway/routers/memory.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field, field_validator from app.gateway.authz import require_permission from app.gateway.internal_auth import get_trusted_internal_owner_user_id from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager +from deerflow.config.agents_config import AGENT_NAME_PATTERN from deerflow.config.memory_config import get_memory_config from deerflow.config.paths import make_safe_user_id from deerflow.runtime.user_context import get_effective_user_id @@ -154,7 +155,46 @@ def _unsupported_501(manager: object, label: str) -> HTTPException: ) -async def _get_memory_or_501(manager: MemoryManager, user_id: str, label: str) -> dict[str, Any]: +def _management_agent_name_or_501( + manager: MemoryManager, + agent_name: str | None, +) -> str | None: + """Validate an optional agent-scoped management target. + + A backend must explicitly declare that its management operations bind to + ``agent_name``. Merely accepting the keyword is not enough: adapters may + expose user-global memory and ignore it, which would turn a scoped edit + into a mutation of the wrong bucket. Preserve the caller's spelling here: + remote backends such as Mem0 use the agent name as a case-sensitive storage + identity, while DeerMem performs its own lowercase canonicalization. + """ + if agent_name is None: + return None + if not AGENT_NAME_PATTERN.fullmatch(agent_name): + raise HTTPException( + status_code=422, + detail=(f"Invalid agent name '{agent_name}'. Must match {AGENT_NAME_PATTERN.pattern} (letters, digits, and hyphens only)."), + ) + if getattr(manager, "supports_agent_scoped_management", False) is not True: + raise HTTPException( + status_code=501, + detail=(f"Operation 'agent-scoped management' not supported by memory backend '{type(manager).__name__}'."), + ) + return agent_name + + +def _agent_scope_kwargs(agent_name: str | None) -> dict[str, str]: + """Pass an agent scope only when the caller explicitly selected one.""" + return {"agent_name": agent_name} if agent_name is not None else {} + + +async def _get_memory_or_501( + manager: MemoryManager, + user_id: str, + label: str, + *, + agent_name: str | None = None, +) -> dict[str, Any]: """Read the full memory doc; 501 if the backend doesn't expose one. ``get_memory`` is tier-2 (default ``raise NotImplementedError``); a minimal @@ -164,8 +204,13 @@ async def _get_memory_or_501(manager: MemoryManager, user_id: str, label: str) - of a raw 500. ``label`` is the operation name in the 501 detail (the endpoint's verb, e.g. "get memory" / "export memory" / "reload memory"). """ + scope_kwargs = _agent_scope_kwargs(agent_name) try: - return await asyncio.to_thread(manager.get_memory, user_id=user_id) + return await asyncio.to_thread( + manager.get_memory, + user_id=user_id, + **scope_kwargs, + ) except NotImplementedError: raise _unsupported_501(manager, label) from None except (MemoryConflictError, MemoryCorruptionError) as exc: @@ -211,11 +256,11 @@ class MemoryStatusResponse(BaseModel): response_model=MemoryResponse, response_model_exclude_none=True, summary="Get Memory Data", - description="Retrieve the current global memory data including user context, history, and facts.", + description="Retrieve one selected agent's memory, or the default bucket when agent_name is omitted.", ) @require_permission("memory", "read") -async def get_memory(request: Request) -> MemoryResponse: - """Get the current global memory data. +async def get_memory(request: Request, agent_name: str | None = None) -> MemoryResponse: + """Get memory data for one agent scope or the default bucket. Returns: The current memory data with user context, history, and facts. @@ -249,7 +294,13 @@ async def get_memory(request: Request) -> MemoryResponse: ``` """ manager = await asyncio.to_thread(get_memory_manager) - memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "get memory") + selected_agent = _management_agent_name_or_501(manager, agent_name) + memory_data = await _get_memory_or_501( + manager, + _resolve_memory_user_id(request), + "get memory", + agent_name=selected_agent, + ) return MemoryResponse(**memory_data) @@ -258,10 +309,10 @@ async def get_memory(request: Request) -> MemoryResponse: response_model=MemoryResponse, response_model_exclude_none=True, summary="Reload Memory Data", - description="Reload memory data from the storage file, refreshing the in-memory cache.", + description="Reload one selected agent's memory, or the default bucket when agent_name is omitted.", ) @require_permission("memory", "read") -async def reload_memory(request: Request) -> MemoryResponse: +async def reload_memory(request: Request, agent_name: str | None = None) -> MemoryResponse: """Reload memory data from file. This forces a reload of the memory data from the storage file, @@ -272,8 +323,14 @@ async def reload_memory(request: Request) -> MemoryResponse: """ user_id = _resolve_memory_user_id(request) manager = await asyncio.to_thread(get_memory_manager) + selected_agent = _management_agent_name_or_501(manager, agent_name) + scope_kwargs = _agent_scope_kwargs(selected_agent) try: - memory_data = await asyncio.to_thread(manager.reload_memory, user_id=user_id) + memory_data = await asyncio.to_thread( + manager.reload_memory, + user_id=user_id, + **scope_kwargs, + ) except NotImplementedError: # Non-DeerMem backends have no reload concept; fall back to get_memory # (read-only refresh, so degrading is safe and still useful -- vs fact @@ -281,7 +338,12 @@ async def reload_memory(request: Request) -> MemoryResponse: # would hide data loss). If get_memory is also unsupported (a minimal # backend with no full doc), surface 501 rather than a raw 500: reads # degrade only when there is a doc to degrade to. - memory_data = await _get_memory_or_501(manager, user_id, "reload memory") + memory_data = await _get_memory_or_501( + manager, + user_id, + "reload memory", + agent_name=selected_agent, + ) except (MemoryConflictError, MemoryCorruptionError) as exc: raise _map_memory_manager_error(exc) from exc return MemoryResponse(**memory_data) @@ -291,15 +353,21 @@ async def reload_memory(request: Request) -> MemoryResponse: "/memory", response_model=MemoryResponse, response_model_exclude_none=True, - summary="Clear All Memory Data", - description="Delete all saved memory data and reset the memory structure to an empty state.", + summary="Clear Memory Data", + description="Delete one selected agent's memory, or all saved memory for the current user when agent_name is omitted.", ) @require_permission("memory", "write") -async def clear_memory(request: Request) -> MemoryResponse: - """Clear all persisted memory data.""" +async def clear_memory(request: Request, agent_name: str | None = None) -> MemoryResponse: + """Clear one agent bucket, or all user memory when no scope is selected.""" manager = await asyncio.to_thread(get_memory_manager) + selected_agent = _management_agent_name_or_501(manager, agent_name) + scope_kwargs = _agent_scope_kwargs(selected_agent) try: - memory_data = await asyncio.to_thread(manager.clear_memory, user_id=_resolve_memory_user_id(request)) + memory_data = await asyncio.to_thread( + manager.clear_memory, + user_id=_resolve_memory_user_id(request), + **scope_kwargs, + ) except NotImplementedError: raise _unsupported_501(manager, "clear memory") from None except (MemoryConflictError, MemoryCorruptionError) as exc: @@ -318,9 +386,11 @@ async def clear_memory(request: Request) -> MemoryResponse: description="Create a single saved memory fact manually.", ) @require_permission("memory", "write") -async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request) -> MemoryResponse: +async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request, agent_name: str | None = None) -> MemoryResponse: """Create a single fact manually.""" manager = await asyncio.to_thread(get_memory_manager) + selected_agent = _management_agent_name_or_501(manager, agent_name) + scope_kwargs = _agent_scope_kwargs(selected_agent) try: memory_data, fact_id = await asyncio.to_thread( manager.create_fact, @@ -328,6 +398,7 @@ async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request) category=body.category, confidence=body.confidence, user_id=_resolve_memory_user_id(request), + **scope_kwargs, ) except NotImplementedError: raise _unsupported_501(manager, "create fact") from None @@ -352,11 +423,18 @@ async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request) description="Delete a single saved memory fact by its fact id.", ) @require_permission("memory", "write") -async def delete_memory_fact_endpoint(fact_id: str, request: Request) -> MemoryResponse: +async def delete_memory_fact_endpoint(fact_id: str, request: Request, agent_name: str | None = None) -> MemoryResponse: """Delete a single fact from memory by fact id.""" manager = await asyncio.to_thread(get_memory_manager) + selected_agent = _management_agent_name_or_501(manager, agent_name) + scope_kwargs = _agent_scope_kwargs(selected_agent) try: - memory_data = await asyncio.to_thread(manager.delete_fact, fact_id, user_id=_resolve_memory_user_id(request)) + memory_data = await asyncio.to_thread( + manager.delete_fact, + fact_id, + user_id=_resolve_memory_user_id(request), + **scope_kwargs, + ) except NotImplementedError: raise _unsupported_501(manager, "delete fact") from None except KeyError as exc: @@ -377,9 +455,11 @@ async def delete_memory_fact_endpoint(fact_id: str, request: Request) -> MemoryR description="Partially update a single saved memory fact by its fact id while preserving omitted fields.", ) @require_permission("memory", "write") -async def update_memory_fact_endpoint(fact_id: str, body: FactPatchRequest, request: Request) -> MemoryResponse: +async def update_memory_fact_endpoint(fact_id: str, body: FactPatchRequest, request: Request, agent_name: str | None = None) -> MemoryResponse: """Partially update a single fact manually.""" manager = await asyncio.to_thread(get_memory_manager) + selected_agent = _management_agent_name_or_501(manager, agent_name) + scope_kwargs = _agent_scope_kwargs(selected_agent) try: memory_data = await asyncio.to_thread( manager.update_fact, @@ -388,6 +468,7 @@ async def update_memory_fact_endpoint(fact_id: str, body: FactPatchRequest, requ category=body.category, confidence=body.confidence, user_id=_resolve_memory_user_id(request), + **scope_kwargs, ) except NotImplementedError: raise _unsupported_501(manager, "update fact") from None @@ -408,13 +489,19 @@ async def update_memory_fact_endpoint(fact_id: str, body: FactPatchRequest, requ response_model=MemoryResponse, response_model_exclude_none=True, summary="Export Memory Data", - description="Export the current global memory data as JSON for backup or transfer.", + description="Export one selected agent's memory as JSON, or the default bucket when agent_name is omitted.", ) @require_permission("memory", "read") -async def export_memory(request: Request) -> MemoryResponse: +async def export_memory(request: Request, agent_name: str | None = None) -> MemoryResponse: """Export the current memory data.""" manager = await asyncio.to_thread(get_memory_manager) - memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "export memory") + selected_agent = _management_agent_name_or_501(manager, agent_name) + memory_data = await _get_memory_or_501( + manager, + _resolve_memory_user_id(request), + "export memory", + agent_name=selected_agent, + ) return MemoryResponse(**memory_data) @@ -423,17 +510,20 @@ async def export_memory(request: Request) -> MemoryResponse: response_model=MemoryResponse, response_model_exclude_none=True, summary="Import Memory Data", - description="Import and overwrite the current global memory data from a JSON payload.", + description="Import the default memory document, or replace only one selected agent's facts while preserving shared summaries.", ) @require_permission("memory", "write") -async def import_memory(body: MemoryResponse, request: Request) -> MemoryResponse: - """Import and persist memory data.""" +async def import_memory(body: MemoryResponse, request: Request, agent_name: str | None = None) -> MemoryResponse: + """Import memory data, preserving shared summaries for a selected agent.""" manager = await asyncio.to_thread(get_memory_manager) + selected_agent = _management_agent_name_or_501(manager, agent_name) + scope_kwargs = _agent_scope_kwargs(selected_agent) try: memory_data = await asyncio.to_thread( manager.import_memory, body.model_dump(exclude_none=True), user_id=_resolve_memory_user_id(request), + **scope_kwargs, ) except NotImplementedError: raise _unsupported_501(manager, "import memory") from None @@ -502,10 +592,10 @@ async def get_memory_config_endpoint(request: Request) -> MemoryConfigResponse: response_model=MemoryStatusResponse, response_model_exclude_none=True, summary="Get Memory Status", - description="Retrieve both memory configuration and current data in a single request.", + description="Retrieve memory configuration and one selected agent's data, using the default bucket when agent_name is omitted.", ) @require_permission("memory", "read") -async def get_memory_status(request: Request) -> MemoryStatusResponse: +async def get_memory_status(request: Request, agent_name: str | None = None) -> MemoryStatusResponse: """Get the memory system status including configuration and data. Returns: @@ -513,7 +603,13 @@ async def get_memory_status(request: Request) -> MemoryStatusResponse: """ config = get_memory_config() manager = await asyncio.to_thread(get_memory_manager) - memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "get memory status") + selected_agent = _management_agent_name_or_501(manager, agent_name) + memory_data = await _get_memory_or_501( + manager, + _resolve_memory_user_id(request), + "get memory status", + agent_name=selected_agent, + ) return MemoryStatusResponse( config=MemoryConfigResponse( diff --git a/backend/packages/harness/deerflow/agents/memory/AGENTS.md b/backend/packages/harness/deerflow/agents/memory/AGENTS.md index 1bee3974b2..13c8b1d82f 100644 --- a/backend/packages/harness/deerflow/agents/memory/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/memory/AGENTS.md @@ -63,7 +63,22 @@ The legacy shared agent layout is read-only fallback data. DeerMem maps a missing agent name to `__default__`. That name is reserved and cannot identify a custom agent. -Public agent names use lowercase canonical form. +DeerMem canonicalizes public agent names to lowercase for local storage. +Remote backends may preserve the case-sensitive identity used when facts were +written. + +Gateway management reads, reload, import/export, clear, and single-fact CRUD +accept an optional `agent_name`. A backend must opt in with +`supports_agent_scoped_management = True`; otherwise a scoped request returns +501 instead of silently operating on user-global or default-bucket data. +Omitting the parameter preserves the legacy default bucket for reads, reload, +import/export, and fact CRUD; omitting it from clear preserves the legacy +user-wide clear. Gateway validates the public agent-name grammar but preserves +the caller's spelling so case-sensitive remote identities remain reachable; +each backend owns any storage-specific canonicalization. +Scoped import replaces only the selected agent's facts. It always preserves the +user's shared `user` and `history` summaries, including when an older or +fact-only import payload supplies empty summary defaults. #### Operating modes diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py index 63bc9f17ad..7f95027b41 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py @@ -111,6 +111,7 @@ class DeerMem(MemoryManager): # for tool mode). Backends without real search inherit the False default and # cannot be used with mode="tool". supports_search: ClassVar[bool] = True + supports_agent_scoped_management: ClassVar[bool] = True def model_post_init(self, __context: Any) -> None: """Construct DeerMem's dependencies from ``self.backend_config``. @@ -575,6 +576,7 @@ class DeerMem(MemoryManager): memory_data, agent_name=_resolve_agent_name(agent_name), user_id=user_id, + replace_shared_summaries=agent_name is None, ) ) return _compat_document(imported) diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py index 0ea7481891..362d3f08f1 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py @@ -960,7 +960,14 @@ class MemoryUpdater: except Exception: logger.warning("Failed to record capacity-eviction audit", exc_info=True) - def import_memory_data(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]: + def import_memory_data( + self, + memory_data: dict[str, Any], + agent_name: str | None = None, + *, + user_id: str | None = None, + replace_shared_summaries: bool = True, + ) -> dict[str, Any]: """Persist imported memory data via the injected storage.""" if not isinstance(memory_data, dict): raise ValueError("memory_data") @@ -985,14 +992,19 @@ class MemoryUpdater: ) current_by_id = {str(fact.get("id")): fact for fact in current.get("facts", []) if isinstance(fact, dict)} incoming_ids = {str(fact.get("id")) for fact in incoming_facts} + change_set = { + "upserts": incoming_facts, + "upsertRevisions": {str(fact.get("id")): (int(current_by_id[str(fact.get("id"))].get("revision") or 1) if str(fact.get("id")) in current_by_id else None) for fact in incoming_facts}, + "deletes": [fact_id for fact_id in current_by_id if fact_id not in incoming_ids], + "deleteRevisions": {fact_id: int(fact.get("revision") or 1) for fact_id, fact in current_by_id.items() if fact_id not in incoming_ids}, + } + if replace_shared_summaries: + change_set["summaries"] = { + "user": copy.deepcopy(memory_data.get("user", {})), + "history": copy.deepcopy(memory_data.get("history", {})), + } self._storage.apply_changes( - { - "upserts": incoming_facts, - "upsertRevisions": {str(fact.get("id")): (int(current_by_id[str(fact.get("id"))].get("revision") or 1) if str(fact.get("id")) in current_by_id else None) for fact in incoming_facts}, - "deletes": [fact_id for fact_id in current_by_id if fact_id not in incoming_ids], - "deleteRevisions": {fact_id: int(fact.get("revision") or 1) for fact_id, fact in current_by_id.items() if fact_id not in incoming_ids}, - "summaries": {"user": copy.deepcopy(memory_data.get("user", {})), "history": copy.deepcopy(memory_data.get("history", {}))}, - }, + change_set, agent_name=agent_name, user_id=user_id, expected_manifest_revision=int(current.get("revision") or 0), diff --git a/backend/packages/harness/deerflow/agents/memory/backends/mem0/mem0_manager.py b/backend/packages/harness/deerflow/agents/memory/backends/mem0/mem0_manager.py index 879f267ff2..4126639de5 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/mem0/mem0_manager.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/mem0/mem0_manager.py @@ -72,6 +72,9 @@ class Mem0Manager(MemoryManager): # search() is overridden below -> flag must be True (contract invariant); # this also enables memory mode="tool". supports_search: ClassVar[bool] = True + # Mem0 binds management reads/clears to ``agent_id`` in the remote filter. + # Fact CRUD remains unsupported and continues to return the base 501. + supports_agent_scoped_management: ClassVar[bool] = True # mem0 extracts/deduplicates facts from full conversations through add(); # its fact CRUD hooks are intentionally unsupported, so tool mode retains # passive writes while exposing query-aware search. diff --git a/backend/packages/harness/deerflow/agents/memory/manager.py b/backend/packages/harness/deerflow/agents/memory/manager.py index 09ce744b68..a364aaa6ae 100644 --- a/backend/packages/harness/deerflow/agents/memory/manager.py +++ b/backend/packages/harness/deerflow/agents/memory/manager.py @@ -192,6 +192,14 @@ class MemoryManager(BaseModel): # that fails fast at instantiation rather than silently returning empty # results). Default False: a new backend must explicitly opt in to tool mode. supports_search: ClassVar[bool] = False + # Opt-in capability for Gateway management calls that name an agent scope. + # The HTTP layer rejects a scoped read/write unless the backend declares + # this flag, because accepting ``agent_name`` in a Python signature does + # not prove that an adapter actually binds storage and mutations to it. + # Backends that leave it False retain the unscoped management API and get + # an explicit 501 for scoped management instead of silently operating on + # the user's default/global bucket. + supports_agent_scoped_management: ClassVar[bool] = False # Backends that rely on conversation-level extraction instead of fact CRUD # can retain MemoryMiddleware writes while tool mode supplies query-aware # search. Most backends keep tool mode fully model-directed. @@ -417,7 +425,11 @@ class MemoryManager(BaseModel): agent_name: str | None = None, ) -> dict[str, Any]: """Import a memory document into the bucket; return the merged result. - Default: unsupported.""" + + An explicit ``agent_name`` replaces only that agent's facts. Shared + user/history summaries must remain unchanged even when the incoming + document contains summary fields. Default: unsupported. + """ raise NotImplementedError(f"import_memory not supported by {type(self).__name__}") def export_memory( diff --git a/backend/tests/test_mem0_memory_backend.py b/backend/tests/test_mem0_memory_backend.py index 6a2254590c..e18fcbc7fa 100644 --- a/backend/tests/test_mem0_memory_backend.py +++ b/backend/tests/test_mem0_memory_backend.py @@ -640,6 +640,25 @@ class TestMem0ManagerManage: mgr, _fake = _manager() assert mgr.get_memory() == {"facts": []} + def test_mixed_case_agent_identity_matches_write_read_and_clear(self) -> None: + """Management must keep the exact agent_id used by existing writes.""" + mgr, fake = _manager() + agent_name = "Research-Agent" + mgr.add( + "thread-1", + [HumanMessage(content="I prefer concise reports"), AIMessage(content="Noted.")], + user_id="u1", + agent_name=agent_name, + ) + fake.list_results = [{"id": "m1", "memory": "prefers concise reports"}] + + assert mgr.get_memory(user_id="u1", agent_name=agent_name)["facts"][0]["id"] == "m1" + assert mgr.clear_memory(user_id="u1", agent_name=agent_name) == {"facts": []} + + assert fake.added[0]["agent_id"] == agent_name + assert fake.list_calls[0]["filters"] == {"AND": [{"user_id": "u1"}, {"agent_id": agent_name}]} + assert fake.deleted == [{"user_id": "u1", "agent_id": agent_name, "run_id": None}] + def test_clear_memory_deletes_bucket_and_returns_empty(self) -> None: mgr, fake = _manager() assert mgr.clear_memory(user_id="u1", agent_name="lead_agent") == {"facts": []} diff --git a/backend/tests/test_memory_router.py b/backend/tests/test_memory_router.py index 6afc05a08b..c09c3949fb 100644 --- a/backend/tests/test_memory_router.py +++ b/backend/tests/test_memory_router.py @@ -109,6 +109,7 @@ def test_import_memory_route_returns_imported_memory() -> None: response = client.post("/api/memory/import", json=imported_memory) assert response.status_code == 200 assert response.json()["facts"] == imported_memory["facts"] + assert "agent_name" not in mock_mgr.import_memory.call_args.kwargs def test_import_route_without_agent_name_persists_default_bucket_markdown(tmp_path) -> None: @@ -180,6 +181,7 @@ def test_clear_memory_route_returns_cleared_memory() -> None: response = client.delete("/api/memory") assert response.status_code == 200 assert response.json()["facts"] == [] + assert "agent_name" not in mock_mgr.clear_memory.call_args.kwargs # ── fact CRUD (normal / error) ───────────────────────────────────────────── @@ -199,6 +201,225 @@ def test_create_memory_fact_route_returns_updated_memory() -> None: assert response.json()["facts"] == updated_memory["facts"] +def test_scoped_memory_read_and_fact_crud_preserve_requested_agent_name() -> None: + """The gateway must not remap a case-sensitive remote backend identity.""" + app = make_authed_test_app() + app.include_router(memory.router) + selected_memory = _sample_memory( + facts=[ + { + "id": "fact_shared-id", + "content": "Research agent preference", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + manager = MagicMock() + manager.supports_agent_scoped_management = True + manager.get_memory.return_value = selected_memory + manager.create_fact.return_value = (selected_memory, "fact_shared-id") + manager.update_fact.return_value = selected_memory + manager.delete_fact.return_value = _sample_memory() + + with ( + patch("app.gateway.routers.memory.get_memory_manager", return_value=manager), + patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"), + TestClient(app) as client, + ): + fetched = client.get("/api/memory?agent_name=Research-Agent") + created = client.post( + "/api/memory/facts?agent_name=Research-Agent", + json={"content": "Research agent preference"}, + ) + updated = client.patch( + "/api/memory/facts/fact_shared-id?agent_name=Research-Agent", + json={"confidence": 0.95}, + ) + deleted = client.delete("/api/memory/facts/fact_shared-id?agent_name=Research-Agent") + + assert fetched.status_code == 200 + assert created.status_code == 200 + assert updated.status_code == 200 + assert deleted.status_code == 200 + manager.get_memory.assert_called_once_with(user_id="alice", agent_name="Research-Agent") + assert manager.create_fact.call_args.kwargs["agent_name"] == "Research-Agent" + assert manager.update_fact.call_args.kwargs["agent_name"] == "Research-Agent" + assert manager.delete_fact.call_args.kwargs["agent_name"] == "Research-Agent" + + +def test_scoped_import_and_clear_preserve_requested_agent_name() -> None: + app = make_authed_test_app() + app.include_router(memory.router) + selected_memory = _sample_memory() + manager = MagicMock() + manager.supports_agent_scoped_management = True + manager.import_memory.return_value = selected_memory + manager.clear_memory.return_value = selected_memory + + with ( + patch("app.gateway.routers.memory.get_memory_manager", return_value=manager), + patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"), + TestClient(app) as client, + ): + imported = client.post("/api/memory/import?agent_name=Research-Agent", json=selected_memory) + cleared = client.delete("/api/memory?agent_name=Research-Agent") + + assert imported.status_code == 200 + assert cleared.status_code == 200 + assert manager.import_memory.call_args.kwargs == {"user_id": "alice", "agent_name": "Research-Agent"} + assert manager.clear_memory.call_args.kwargs == {"user_id": "alice", "agent_name": "Research-Agent"} + + +def test_scoped_fact_import_preserves_shared_summaries(tmp_path) -> None: + app = make_authed_test_app() + app.include_router(memory.router) + manager = DeerMem(backend_config={"storage_path": str(tmp_path)}) + shared_memory = _sample_memory() + shared_memory["user"]["workContext"] = { + "summary": "Shared work context", + "updatedAt": "2026-09-30T00:00:00Z", + } + shared_memory["history"]["recentMonths"] = { + "summary": "Shared recent history", + "updatedAt": "2026-09-30T00:00:00Z", + } + manager.import_memory(shared_memory, user_id="alice") + fact_only_import = { + "facts": [ + { + "id": "fact-agent-a", + "content": "Agent A preference", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-09-30T00:00:00Z", + "source": "import", + } + ] + } + + with ( + patch("app.gateway.routers.memory.get_memory_manager", return_value=manager), + patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"), + TestClient(app) as client, + ): + response = client.post( + "/api/memory/import?agent_name=agent-a", + json=fact_only_import, + ) + + assert response.status_code == 200 + imported = response.json() + assert [fact["id"] for fact in imported["facts"]] == ["fact-agent-a"] + assert imported["user"]["workContext"]["summary"] == "Shared work context" + assert imported["history"]["recentMonths"]["summary"] == "Shared recent history" + + +def test_scoped_import_export_and_clear_are_isolated_between_agent_buckets(tmp_path) -> None: + app = make_authed_test_app() + app.include_router(memory.router) + manager = DeerMem(backend_config={"storage_path": str(tmp_path)}) + agent_a = _sample_memory( + facts=[ + { + "id": "fact-agent-a", + "content": "Agent A preference", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-03-20T00:00:00Z", + "source": "import", + } + ] + ) + agent_b = _sample_memory( + facts=[ + { + "id": "fact-agent-b", + "content": "Agent B preference", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-03-20T00:00:00Z", + "source": "import", + } + ] + ) + + with ( + patch("app.gateway.routers.memory.get_memory_manager", return_value=manager), + patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"), + TestClient(app) as client, + ): + assert client.post("/api/memory/import?agent_name=agent-a", json=agent_a).status_code == 200 + assert client.post("/api/memory/import?agent_name=agent-b", json=agent_b).status_code == 200 + exported_a = client.get("/api/memory/export?agent_name=agent-a") + cleared_a = client.delete("/api/memory?agent_name=agent-a") + exported_b = client.get("/api/memory/export?agent_name=agent-b") + + assert [fact["id"] for fact in exported_a.json()["facts"]] == ["fact-agent-a"] + assert cleared_a.status_code == 200 + assert cleared_a.json()["facts"] == [] + assert [fact["id"] for fact in exported_b.json()["facts"]] == ["fact-agent-b"] + + +def test_scoped_management_rejects_backend_without_scope_capability() -> None: + """A backend that ignores agent_name must not silently expose the default bucket.""" + app = make_authed_test_app() + app.include_router(memory.router) + manager = MagicMock() + manager.supports_agent_scoped_management = False + + with patch("app.gateway.routers.memory.get_memory_manager", return_value=manager): + with TestClient(app) as client: + read_response = client.get("/api/memory?agent_name=research-agent") + import_response = client.post("/api/memory/import?agent_name=research-agent", json=_sample_memory()) + clear_response = client.delete("/api/memory?agent_name=research-agent") + + assert read_response.status_code == 501 + assert import_response.status_code == 501 + assert clear_response.status_code == 501 + assert "agent-scoped management" in read_response.json()["detail"] + manager.get_memory.assert_not_called() + manager.import_memory.assert_not_called() + manager.clear_memory.assert_not_called() + + +def test_scoped_memory_read_rejects_malformed_agent_name() -> None: + app = make_authed_test_app() + app.include_router(memory.router) + manager = MagicMock() + manager.supports_agent_scoped_management = True + + with patch("app.gateway.routers.memory.get_memory_manager", return_value=manager): + with TestClient(app) as client: + response = client.get("/api/memory?agent_name=../other-user") + + assert response.status_code == 422 + manager.get_memory.assert_not_called() + + +def test_scoped_reload_fallback_reads_the_same_agent_bucket() -> None: + """A backend without reload support must not lose scope on read fallback.""" + app = make_authed_test_app() + app.include_router(memory.router) + manager = MagicMock() + manager.supports_agent_scoped_management = True + manager.reload_memory.side_effect = NotImplementedError("no cache") + manager.get_memory.return_value = _sample_memory() + + with ( + patch("app.gateway.routers.memory.get_memory_manager", return_value=manager), + patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"), + TestClient(app) as client, + ): + response = client.post("/api/memory/reload?agent_name=Research-Agent") + + assert response.status_code == 200 + manager.reload_memory.assert_called_once_with(user_id="alice", agent_name="Research-Agent") + manager.get_memory.assert_called_once_with(user_id="alice", agent_name="Research-Agent") + + def test_create_memory_fact_route_maps_conflict_to_409() -> None: app = make_authed_test_app() app.include_router(memory.router) @@ -338,6 +559,55 @@ def test_settings_fact_crud_without_agent_name_uses_default_agent(tmp_path) -> N assert "facts" not in json.loads(memory_path.read_text(encoding="utf-8")) +def test_scoped_fact_mutation_does_not_cross_agent_bucket_with_same_fact_id( + tmp_path, +) -> None: + app = make_authed_test_app() + app.include_router(memory.router) + manager = DeerMem(backend_config={"storage_path": str(tmp_path)}) + shared_id = "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ" + agent_a = _sample_memory( + facts=[ + { + "id": shared_id, + "content": "Agent A original", + "category": "context", + "confidence": 0.8, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + agent_b = _sample_memory( + facts=[ + { + "id": shared_id, + "content": "Agent B original", + "category": "context", + "confidence": 0.8, + "createdAt": "2026-03-20T00:00:00Z", + "source": "manual", + } + ] + ) + manager.import_memory(agent_a, user_id="alice", agent_name="agent-a") + manager.import_memory(agent_b, user_id="alice", agent_name="agent-b") + + with ( + patch("app.gateway.routers.memory.get_memory_manager", return_value=manager), + patch("app.gateway.routers.memory.get_effective_user_id", return_value="alice"), + TestClient(app) as client, + ): + response = client.patch( + f"/api/memory/facts/{shared_id}?agent_name=agent-a", + json={"content": "Agent A updated"}, + ) + + assert response.status_code == 200 + assert manager.get_memory(user_id="alice", agent_name="agent-a")["facts"][0]["content"] == "Agent A updated" + assert manager.get_memory(user_id="alice", agent_name="agent-b")["facts"][0]["content"] == "Agent B original" + + def test_update_memory_fact_route_preserves_omitted_fields() -> None: app = make_authed_test_app() app.include_router(memory.router) diff --git a/frontend/src/app/api/memory/[...path]/route.ts b/frontend/src/app/api/memory/[...path]/route.ts index 8de48d7425..a335370bf5 100644 --- a/frontend/src/app/api/memory/[...path]/route.ts +++ b/frontend/src/app/api/memory/[...path]/route.ts @@ -3,8 +3,10 @@ import type { NextRequest } from "next/server"; const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://127.0.0.1:8001"; -function buildBackendUrl(pathname: string) { - return new URL(pathname, BACKEND_BASE_URL); +function buildBackendUrl(request: NextRequest, pathname: string) { + const url = new URL(pathname, BACKEND_BASE_URL); + url.search = request.nextUrl.search; + return url; } async function proxyRequest(request: NextRequest, pathname: string) { @@ -14,7 +16,7 @@ async function proxyRequest(request: NextRequest, pathname: string) { headers.delete("content-length"); const hasBody = !["GET", "HEAD"].includes(request.method); - const response = await fetch(buildBackendUrl(pathname), { + const response = await fetch(buildBackendUrl(request, pathname), { method: request.method, headers, body: hasBody ? await request.arrayBuffer() : undefined, diff --git a/frontend/src/app/api/memory/route.ts b/frontend/src/app/api/memory/route.ts index 2957c69c1a..20465b4a0d 100644 --- a/frontend/src/app/api/memory/route.ts +++ b/frontend/src/app/api/memory/route.ts @@ -3,8 +3,10 @@ import type { NextRequest } from "next/server"; const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://127.0.0.1:8001"; -function buildBackendUrl(pathname: string) { - return new URL(pathname, BACKEND_BASE_URL); +function buildBackendUrl(request: NextRequest, pathname: string) { + const url = new URL(pathname, BACKEND_BASE_URL); + url.search = request.nextUrl.search; + return url; } async function proxyRequest(request: NextRequest, pathname: string) { @@ -14,7 +16,7 @@ async function proxyRequest(request: NextRequest, pathname: string) { headers.delete("content-length"); const hasBody = !["GET", "HEAD"].includes(request.method); - const response = await fetch(buildBackendUrl(pathname), { + const response = await fetch(buildBackendUrl(request, pathname), { method: request.method, headers, body: hasBody ? await request.arrayBuffer() : undefined, diff --git a/frontend/tests/unit/app/api/memory-route.test.ts b/frontend/tests/unit/app/api/memory-route.test.ts new file mode 100644 index 0000000000..61f7d3cc35 --- /dev/null +++ b/frontend/tests/unit/app/api/memory-route.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { NextRequest } from "next/server"; + +import { POST as postMemoryPath } from "@/app/api/memory/[...path]/route"; +import { DELETE as deleteMemory } from "@/app/api/memory/route"; + +describe("memory API proxy", () => { + afterEach(() => { + rs.restoreAllMocks(); + }); + + it("preserves the selected agent on root destructive requests", async () => { + const fetchMock = rs + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("{}", { status: 200 })); + const request = new NextRequest( + "http://deer-flow.test/api/memory?agent_name=Research-Agent", + { method: "DELETE" }, + ); + + await deleteMemory(request); + + const [target] = fetchMock.mock.calls[0]!; + expect(target).toBeInstanceOf(URL); + if (!(target instanceof URL)) { + throw new Error("Memory proxy target must be a URL"); + } + expect(target.href).toBe( + "http://127.0.0.1:8001/api/memory?agent_name=Research-Agent", + ); + }); + + it("preserves the full query string on nested memory requests", async () => { + const fetchMock = rs + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("{}", { status: 200 })); + const request = new NextRequest( + "http://deer-flow.test/api/memory/import?agent_name=Research-Agent&mode=facts%2Bmetadata", + { + method: "POST", + body: JSON.stringify({ facts: [] }), + }, + ); + + await postMemoryPath(request, { + params: Promise.resolve({ path: ["import"] }), + }); + + const [target] = fetchMock.mock.calls[0]!; + expect(target).toBeInstanceOf(URL); + if (!(target instanceof URL)) { + throw new Error("Memory proxy target must be a URL"); + } + expect(target.href).toBe( + "http://127.0.0.1:8001/api/memory/import?agent_name=Research-Agent&mode=facts%2Bmetadata", + ); + }); +});