bytedance/deer-flow · error · ValueError
change_set.upserts and change_set.deletes must be lists
Error message
change_set.upserts and change_set.deletes must be lists
What it means
apply_changes() requires change_set['upserts'] and change_set['deletes'] to be lists (empty or absent defaults to []). This is the structural gate before per-element validation; anything non-list (string, dict, null explicitly set, int) is rejected without touching storage.
Source
Thrown at backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py:1251
allow_manifest_rebase: bool = False,
) -> dict[str, Any]:
"""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_revisionView on GitHub (pinned to 1dd6ba1acb)
Solutions
- Wrap single facts in a list and pass deletes as a list of id strings.
- Omit empty keys instead of setting them to null: {'upserts': [fact]} not {'upserts': [fact], 'deletes': None}.
- Add a schema check (pydantic model or manual isinstance) at the boundary that produces change sets.
Example fix
# before
storage.apply_changes({"upserts": fact, "deletes": None}, agent_name=a)
# after
storage.apply_changes({"upserts": [fact]}, agent_name=a) Defensive patterns
Strategy: type-guard
Validate before calling
upserts = change_set.get("upserts", [])
deletes = change_set.get("deletes", [])
if not isinstance(upserts, list) or not isinstance(deletes, list):
raise HTTPException(400, "upserts/deletes must be lists") Type guard
from typing import TypeGuard
def is_change_set(cs: object) -> TypeGuard[dict[str, Any]]:
return (
isinstance(cs, dict)
and isinstance(cs.get("upserts", []), list)
and isinstance(cs.get("deletes", []), list)
) Prevention
- Omit empty keys instead of setting them to null.
- Wrap single facts in a list at construction time.
- Parse inbound change sets with a pydantic model at the API boundary.
When it happens
Trigger: apply_changes({'upserts': fact_dict}) (single dict instead of [fact_dict]); {'upserts': None}; a JSON payload where upserts was serialized as an object keyed by id; deletes given as a comma-joined string.
Common situations: Wrapping/unwrapping bugs when forwarding JSON from an HTTP or MCP boundary; clients treating upserts as optional-null instead of omitting it; converting between dict-based and list-based fact representations.
Related errors
- retrieval fact.id must be a non-empty string
- retrieval fact.content must be a non-empty string
- unsupported FTS5 retrieval mode: {mode}
- retrieval category filter must be a string
- fact.category must be a string
AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14).
Data as JSON: /api/errors/15ee53da0bfdc225.
Report an issue: GitHub.