bytedance/deer-flow · error · ValueError

change_set.upsertRevisions must be an object

Error message

change_set.upsertRevisions must be an object

What it means

apply_changes() validates that change_set['upsertRevisions'], when provided, is a dict mapping fact id -> expected revision (int >= 1 or null). Like deleteRevisions it feeds optimistic-concurrency checks; a non-dict is rejected up front. Note upsert_fact() builds this dict internally, so this fires only on direct apply_changes calls.

Source

Thrown at backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py:1255

        ``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)
        key = self._cache_key(agent_name, user_id=user_id)
        expected = expected_manifest_revision

View on GitHub (pinned to 1dd6ba1acb)

Solutions

  1. Use the wrapper storage.upsert_fact(fact, agent_name=..., expected_fact_revision=rev) for single-fact upserts.
  2. For batch calls, build {'<fact-id>': revision_or_None} per upserted fact.
  3. Omit the key entirely when you have no expected revisions.

Example fix

# before
storage.apply_changes({"upserts": facts, "upsertRevisions": 7}, agent_name=a)

# after
storage.apply_changes({"upserts": facts, "upsertRevisions": {f["id"]: None for f in facts}}, agent_name=a)
Defensive patterns

Strategy: type-guard

Validate before calling

revs = change_set.get("upsertRevisions")
if revs is not None and not isinstance(revs, dict):
    raise HTTPException(400, "upsertRevisions must be an object")
# or simply use the wrapper for single facts:
storage.upsert_fact(fact, agent_name=a, expected_fact_revision=rev)

Type guard

def is_upsert_revision_map(value: object) -> TypeGuard[dict[str, int | None]]:
    return isinstance(value, dict) and all(
        v is None or (isinstance(v, int) and not isinstance(v, bool) and v >= 1)
        for v in value.values()
    )

Prevention

When it happens

Trigger: Direct apply_changes with upsertRevisions as a list, string, or number; passing expected_fact_revision (an int) straight into the change set instead of the {fact_id: rev} map.

Common situations: Bypassing the upsert_fact convenience wrapper without replicating its {'id': revision} shape; JSON clients serializing the map as an array; copy-paste from deleteRevisions handling.

Related errors


AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14). Data as JSON: /api/errors/ae5aea308607f844. Report an issue: GitHub.