bytedance/deer-flow · error · ValueError
change_set.deleteRevisions must be an object
Error message
change_set.deleteRevisions must be an object
What it means
apply_changes() validates that change_set['deleteRevisions'], when provided (not None), is a dict mapping fact id -> expected revision. It powers optimistic-concurrency checks for deletes and the safe-delete-rebase decision; a non-dict value cannot be interpreted and is rejected before any lock is taken.
Source
Thrown at backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py:1253
"""Commit an incremental change set and return only the applied delta.
``complete`` is deliberately false: callers that require the historical
full document must explicitly call ``load``. This prevents a fresh
process from presenting a one-fact cache snapshot as the whole agent
memory while keeping the mutation path free of full fact scans.
"""
has_fact_changes = bool(change_set.get("upserts") or change_set.get("deletes"))
if has_fact_changes and agent_name is None:
raise ValueError("agent_name is required for fact repository changes")
summaries = change_set.get("summaries")
upserts = copy.deepcopy(change_set.get("upserts", []))
deletes = change_set.get("deletes", [])
delete_revisions = change_set.get("deleteRevisions")
upsert_revisions = change_set.get("upsertRevisions")
if not isinstance(upserts, list) or not isinstance(deletes, list):
raise ValueError("change_set.upserts and change_set.deletes must be lists")
if delete_revisions is not None and not isinstance(delete_revisions, dict):
raise ValueError("change_set.deleteRevisions must be an object")
if upsert_revisions is not None and not isinstance(upsert_revisions, dict):
raise ValueError("change_set.upsertRevisions must be an object")
normalized_upsert_revisions: dict[str, int | None] = {}
for incoming in upserts:
if not isinstance(incoming, dict):
raise ValueError("change_set.upserts must contain fact objects")
incoming["id"] = str(incoming.get("id") or f"fact_{uuid.uuid4().hex}")
fact_id = incoming["id"]
if isinstance(upsert_revisions, dict) and fact_id in upsert_revisions:
expected_fact_revision = upsert_revisions[fact_id]
else:
expected_fact_revision = incoming.get("revision") if "revision" in incoming else None
if expected_fact_revision is not None and (isinstance(expected_fact_revision, bool) or not isinstance(expected_fact_revision, int) or expected_fact_revision < 1):
raise ValueError("change_set.upsertRevisions values must be null or integers >= 1")
normalized_upsert_revisions[fact_id] = expected_fact_revision
path = self._get_memory_file_path(agent_name, user_id=user_id)View on GitHub (pinned to 1dd6ba1acb)
Solutions
- Pass {'fact_id': revision_int} or omit/deleteRevisions=None when no per-delete revisions are tracked.
- Convert list-of-pairs to a dict at the boundary: dict(pairs).
- Validate the change_set with a pydantic model before calling apply_changes.
Example fix
# before
storage.apply_changes({"deletes": ids, "deleteRevisions": [123, 124]}, agent_name=a)
# after
storage.apply_changes({"deletes": ids, "deleteRevisions": {fid: 123 for fid in ids}}, agent_name=a) Defensive patterns
Strategy: type-guard
Validate before calling
revs = change_set.get("deleteRevisions")
if revs is not None and not isinstance(revs, dict):
change_set["deleteRevisions"] = dict(revs) # or reject: raise HTTPException(400, ...) Type guard
def is_revision_map(value: object) -> TypeGuard[dict[str, int | None]]:
return isinstance(value, dict) and all(
isinstance(v, (int,)) and not isinstance(v, bool) or v is None
for v in value.values()
) Prevention
- Build revision maps as {fact_id: revision} dicts, never arrays of pairs.
- Validate change_set shape once at the boundary with a schema.
- Keep deleteRevisions keys in sync with the deletes list.
When it happens
Trigger: Passing deleteRevisions as a list of ids, a JSON string, or a dict serialized into another dict shape; forwarding client JSON where the field arrived as a list of [id, rev] pairs.
Common situations: Hand-building change sets from UI state; a client that sends revision metadata as an array; schema drift after renaming/restructuring the field.
Related errors
- fact.revision must be an integer >= 1
- change_set.upsertRevisions must be an object
- change_set.upsertRevisions values must be null or integers >
- retrieval fact.id must be a non-empty string
- retrieval fact.content must be a non-empty string
AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14).
Data as JSON: /api/errors/8a177713eb9e8728.
Report an issue: GitHub.