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
This commit is contained in:
ZephyrWang 2026-11-11 17:35:17 +08:00 committed by GitHub
parent 2a97ec3e58
commit d9b2cb4bcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 50 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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