{"record":{"id":"f5c472e1dedf869c","repo":"bytedance/deer-flow","slug":"memory-data-facts-must-contain-only-fact-objects","errorCode":null,"errorMessage":"memory_data.facts must contain only fact objects","messagePattern":"memory_data\\.facts must contain only fact objects","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py","lineNumber":1085,"sourceCode":"        This API must scan the selected agent to determine which omitted facts\n        are deletions, but the commit writes only new/changed/deleted facts.\n        Repository callers should prefer ``apply_changes`` to avoid even that\n        full comparison scan.\n        \"\"\"\n        path = self._get_memory_file_path(agent_name, user_id=user_id)\n        key = self._cache_key(agent_name, user_id=user_id)\n        lock_path = path.parent / \".memory.lock\"\n        notifications: list[RetrievalNotification] = []\n        try:\n            if not isinstance(memory_data, dict):\n                raise ValueError(\"memory_data must be an object\")\n            if agent_name is not None and \"facts\" not in memory_data:\n                raise ValueError(\"memory_data.facts is required for an agent full save\")\n            facts_raw = memory_data.get(\"facts\", [])\n            if not isinstance(facts_raw, list):\n                raise ValueError(\"memory_data.facts must be a list\")\n            if any(not isinstance(fact, dict) for fact in facts_raw):\n                raise ValueError(\"memory_data.facts must contain only fact objects\")\n            if agent_name is None and facts_raw:\n                raise ValueError(\"agent_name is required to persist facts\")\n            with self._scope_lock(key), _process_file_lock(lock_path, float(getattr(self._config, \"file_lock_timeout_seconds\", 10))):\n                self._recover_if_needed(path)\n                ids = [str(fact.get(\"id\") or \"\") for fact in facts_raw]\n                if len(ids) != len(set(ids)):\n                    raise ValueError(\"Duplicate fact ids are not allowed\")\n                old_ids = set(self._agent_entries(path, agent_name, user_id=user_id)) if agent_name is not None else set()\n                summaries = None\n                if agent_name is None:\n                    summaries = {\"user\": memory_data.get(\"user\", {}), \"history\": memory_data.get(\"history\", {})}\n                _, notifications = self._commit_changes_locked(\n                    path,\n                    user_id=user_id,\n                    agent_name=agent_name,\n                    upserts=copy.deepcopy(facts_raw),\n                    deletes=sorted(old_ids - set(ids)),\n                    summaries=summaries,","sourceCodeStart":1067,"sourceCodeEnd":1103,"githubUrl":"https://github.com/bytedance/deer-flow/blob/1dd6ba1acb03700589994b0366c5d1c7d05e2eff/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py#L1067-L1103","documentation":"Raised by FileMemoryStorage.save() when memory_data['facts'] is a list but at least one element is not a dict. The storage layer validates the shape of a full-save payload before taking the scope/file lock, because every later step (id extraction, deep-copy upserts, delete-diffing) assumes each fact is a JSON object. Any non-dict entry (string, number, list, null) triggers this ValueError.","triggerScenarios":"Calling storage.save(memory_data, agent_name=...) or MemoryUpdater.import_memory_data-equivalent full-save paths where memory_data['facts'] contains e.g. a plain string ('user likes coffee'), a null, or a nested list instead of {\"id\": ..., \"content\": ...} objects.","commonSituations":"Hand-edited memory JSON files; an LLM generating the facts array emitting raw strings instead of objects; a migration script passing a list of contents rather than fact dicts; deserialized JSON where facts was serialized as a map of id->string.","solutions":["Inspect memory_data['facts'] before saving and fix each entry to be a dict (minimally {'id': ..., 'content': ...}).","If facts were produced by an LLM/tool call, add a post-processing step that wraps or rejects non-object entries before they reach save().","Write a failing test reproducing the payload to confirm the fix and prevent regression.","If the data is corrupt on disk, locate the memory file via _get_memory_file_path and repair or clear it."],"exampleFix":"# before\nfacts = [\"user likes coffee\", \"user lives in Berlin\"]\nstorage.save({\"facts\": facts}, agent_name=\"researcher\")\n\n# after\nfacts = [\n    {\"id\": \"fact_1\", \"content\": \"user likes coffee\"},\n    {\"id\": \"fact_2\", \"content\": \"user lives in Berlin\"},\n]\nstorage.save({\"facts\": facts}, agent_name=\"researcher\")","handlingStrategy":"type-guard","validationCode":"def all_facts_are_objects(memory_data: dict) -> bool:\n    facts = memory_data.get(\"facts\", [])\n    return isinstance(facts, list) and all(isinstance(f, dict) for f in facts)\n\nif not all_facts_are_objects(memory_data):\n    raise HTTPException(400, \"facts must be a list of objects\")","typeGuard":"def is_fact_list(value: object) -> TypeGuard[list[dict[str, Any]]]:\n    return isinstance(value, list) and all(isinstance(item, dict) for item in value)","tryCatchPattern":null,"preventionTips":["Validate LLM/tool-produced fact batches with a schema before persisting.","Keep a pydantic model (facts: list[FactModel]) at every boundary that builds memory payloads.","Never hand-edit memory JSON without re-validating its shape."],"tags":["memory","validation","deermem","python"],"backgroundTag":null,"analyzedSha":"1dd6ba1acb03700589994b0366c5d1c7d05e2eff","analyzedAt":"2026-08-14T21:20:34.804Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}