bytedance/deer-flow · error · ValueError

fact.content must be a string

Error message

fact.content must be a string

What it means

fact['content'] must be a str before storage; None, numbers, lists, or dicts raise ValueError. Content is the payload rendered into both JSON and Markdown objects, so its type is non-negotiable. There is no coercion: absent content also fails (None is not str).

Source

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

    scope: dict[str, str | None],
    existing: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Validate one fact and derive its per-item revision.

    The shared JSON revision protects the multi-file transaction.  The fact's
    own revision protects one Markdown object when a disjoint transaction is
    safely rebased after that shared revision changed.
    """
    if not isinstance(fact, dict):
        raise ValueError("fact must be an object")
    normalized = copy.deepcopy(fact)
    normalized["id"] = str(normalized.get("id") or f"fact_{uuid.uuid4().hex}")
    # Validate the id through the canonical path builder's public contract.
    if not normalized["id"] or any(character not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" for character in normalized["id"]):
        raise ValueError("fact.id may contain only letters, numbers, '_' and '-'")
    normalized["schemaVersion"] = 2
    if not isinstance(normalized.get("content"), str):
        raise ValueError("fact.content must be a string")
    normalized["content"] = normalized["content"].strip()
    if not normalized["content"]:
        raise ValueError("fact.content must not be empty")
    _normalize_category(normalized)
    confidence = normalized.get("confidence", 0.5)
    if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
        raise ValueError("fact.confidence must be a number between 0 and 1")
    normalized["confidence"] = float(confidence)
    status = normalized.get("status", "active")
    if status != "active":
        raise ValueError("fact.status must be 'active'; deletion is physical")
    normalized["status"] = "active"
    normalized["scope"] = copy.deepcopy(scope)
    _require_string_list(normalized, "topics")
    _require_string_list(normalized, "consolidatedFrom")
    revision = normalized.get("revision", 1)
    if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
        raise ValueError("fact.revision must be an integer >= 1")

View on GitHub (pinned to 1dd6ba1acb)

Solutions

  1. Extract the text explicitly: use the text member of structured payloads, or str(value) for scalars.
  2. Drop the key only if you also skip the save - absent content still fails validation.
  3. Add a boundary check that rejects non-str content with a clear error naming the offending fact.

Example fix

# before
memory.save_fact({"content": message})  # message is a dict
# after
memory.save_fact({"content": message["text"]})
Defensive patterns

Strategy: type-guard

Validate before calling

content = fact.get("content")
if not isinstance(content, str):
    fact["content"] = content["text"] if isinstance(content, dict) and "text" in content else str(content or "")

Type guard

def has_string_content(fact: dict) -> bool:
    return isinstance(fact.get("content"), str)

Prevention

When it happens

Trigger: Saving {'content': None}, {'content': 42}, or {'content': ['a','b']}; forwarding an LLM message object instead of its text field.

Common situations: Extraction steps returning structured data where text was expected; optional fields left as None by serializers instead of omitted; upstream schema change from text to rich-content objects.

Related errors


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