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
- Extract the text explicitly: use the text member of structured payloads, or str(value) for scalars.
- Drop the key only if you also skip the save - absent content still fails validation.
- 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
- Pull the text member explicitly from structured message objects before saving.
- Assert isinstance(content, str) in extraction code with an error naming the source record.
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
- fact.category must be a string
- fact.{field} must be a list of strings
- fact.source.type must be a string
- fact.source.threadId must be a string or null
- retrieval fact.id must be a non-empty string
AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14).
Data as JSON: /api/errors/6456c914eed75f40.
Report an issue: GitHub.