From d9b2cb4bcbb27a47b3b3c306a84422dd19b459c1 Mon Sep 17 00:00:00 2001 From: ZephyrWang <152375907+Wang-Zephyr@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:35:17 +0800 Subject: [PATCH] fix(memory): require facts in replacement imports (#8131) * fix(memory): require facts in replacement imports * docs(memory): document import facts requirement * docs(changelog): link memory import fix --- CHANGELOG.md | 5 +++++ backend/app/gateway/routers/AGENTS.md | 2 ++ backend/app/gateway/routers/memory.py | 13 ++++++++++-- backend/tests/test_memory_router.py | 30 +++++++++++++++++++++++++++ docs/guide/memory.md | 2 ++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1427ecf8..10dc921f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -238,6 +238,10 @@ This section accumulates work toward the **2.3.0** milestone group binding, which covers every group of the bot, starts a new one in each group. ([#7794]) +- **memory:** Importing a backup without a `facts` array now returns HTTP 422 + and preserves saved memory. An explicit empty array still clears the selected + fact bucket. ([#8131]) + - **memory:** An agent-scoped memory write whose `agent_name` is a Windows reserved device name (`NUL`, `CON`, `COM1`, ...) is now rejected with a `422` on the two routes that create that scope, `POST /memory/facts` and @@ -16299,3 +16303,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#8071]: https://github.com/bytedance/deer-flow/issues/8071 [#8072]: https://github.com/bytedance/deer-flow/pull/8072 [#8126]: https://github.com/bytedance/deer-flow/pull/8126 +[#8131]: https://github.com/bytedance/deer-flow/pull/8131 diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index 911cf6845b..2c230be52d 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -38,6 +38,8 @@ make test-blocking-io # after adding any file, config - **Imports create, they do not overwrite.** Agent and skill imports are scoped to the caller and return 409 on a name collision. Exports leave out credentials, memory, conversations and installation bindings. +- **Memory backup imports** replace the selected facts and require an explicit + `facts` array. Omitting it returns 422. An empty array clears that bucket. - **Outbound requests from a route** (model discovery, connection tests) have a time limit and a size limit, do not follow redirects, and return redacted provider errors. diff --git a/backend/app/gateway/routers/memory.py b/backend/app/gateway/routers/memory.py index 921718305a..b1c231ac9d 100644 --- a/backend/app/gateway/routers/memory.py +++ b/backend/app/gateway/routers/memory.py @@ -120,6 +120,12 @@ class MemoryResponse(BaseModel): facts: list[Fact] = Field(default_factory=list) +class MemoryImportRequest(MemoryResponse): + """Replacement imports must explicitly supply the facts to restore.""" + + facts: list[Fact] = Field(description="Facts to restore. An empty list explicitly clears the selected fact bucket.") + + def _map_memory_fact_value_error(exc: ValueError) -> HTTPException: """Convert updater validation errors into stable API responses.""" if exc.args and exc.args[0] == "confidence": @@ -566,10 +572,13 @@ async def export_memory(request: Request, agent_name: str | None = None) -> Memo response_model=MemoryResponse, response_model_exclude_none=True, summary="Import Memory Data", - description="Import the default memory document, or replace only one selected agent's facts while preserving shared summaries.", + description=( + "Import the default memory document, or replace only one selected agent's facts while preserving shared summaries. " + "The request requires an explicit `facts` array. Omitting it returns HTTP 422, and an empty array clears the selected fact bucket." + ), ) @require_permission("memory", "write") -async def import_memory(body: MemoryResponse, request: Request, agent_name: str | None = None) -> MemoryResponse: +async def import_memory(body: MemoryImportRequest, 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, for_creation=True) diff --git a/backend/tests/test_memory_router.py b/backend/tests/test_memory_router.py index 1fbdc6d174..e2731a1c6f 100644 --- a/backend/tests/test_memory_router.py +++ b/backend/tests/test_memory_router.py @@ -223,6 +223,36 @@ def test_import_route_rejects_invalid_revision_without_replacing_memory(tmp_path manager.close() +@pytest.mark.parametrize("agent_name", [None, "researcher"]) +def test_import_requires_explicit_facts_to_replace_saved_memory(tmp_path, agent_name: str | None) -> None: + app = make_authed_test_app() + app.include_router(memory.router) + manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"}) + params = {"agent_name": agent_name} if agent_name else {} + try: + 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, + ): + created = client.post("/api/memory/facts", params=params, json={"content": "Keep this preference"}) + assert created.status_code == 200 + before = client.get("/api/memory/export", params=params).json() + incomplete_backup = {key: value for key, value in before.items() if key != "facts"} + + response = client.post("/api/memory/import", params=params, json=incomplete_backup) + assert response.status_code == 422, response.text + assert response.json()["detail"][0]["loc"] == ["body", "facts"] + assert client.get("/api/memory/export", params=params).json() == before + + empty_backup = {**incomplete_backup, "facts": []} + cleared = client.post("/api/memory/import", params=params, json=empty_backup) + assert cleared.status_code == 200 + assert client.get("/api/memory/export", params=params).json()["facts"] == [] + finally: + manager.close() + + def test_import_memory_route_preserves_source_error() -> None: app = make_authed_test_app() app.include_router(memory.router) diff --git a/docs/guide/memory.md b/docs/guide/memory.md index d6a6d3e2df..3f4a8a954a 100644 --- a/docs/guide/memory.md +++ b/docs/guide/memory.md @@ -152,6 +152,8 @@ DeerMem backup imports restore saved fact contents even after those facts have been edited, using the current stored revisions to protect the restore. Older backups may omit fact revisions. Backup revisions are validated before the importer substitutes current revisions. Rejected imports preserve stored memory. +The import endpoint requires an explicit `facts` array. Omitting it returns +HTTP 422, while `facts: []` explicitly clears the selected fact bucket. A concurrent write during import still returns HTTP 409 so the caller can reload and retry.