feat(memory): scope management API by agent (#5565)

* feat(memory): scope management API by agent

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

* fix(memory): preserve scoped management isolation

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

---------

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
This commit is contained in:
PeaceMaker-best 2026-09-30 16:50:07 +08:00 committed by GitHub
parent af67738521
commit b73a4e79bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 535 additions and 44 deletions

View File

@ -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(

View File

@ -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

View File

@ -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)

View File

@ -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),

View File

@ -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.

View File

@ -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(

View File

@ -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": []}

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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",
);
});
});