bytedance/deer-flow · error · ValueError

fact.createdAt and fact.updatedAt must be strings

Error message

fact.createdAt and fact.updatedAt must be strings

What it means

After normalization and merge, fact['createdAt'] and fact['updatedAt'] must both be strings. The backend fills them with ISO-8601 UTC 'Z' timestamps when absent, so this fires when the caller supplies non-string values (ints/None/objects) that survive the defaults, or a stored record contributed non-string timestamps during a rebase.

Solutions

  1. Format timestamps as ISO strings via dt.isoformat(), or omit both keys to let the backend stamp them.
  2. During import, convert epochs: datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().
  3. Leave timestamp management to the backend unless you specifically need to preserve source times.

Example fix

# before
memory.save_fact({"content": "...", "createdAt": 1710000000})
# after
from datetime import datetime, timezone
memory.save_fact({"content": "...", "createdAt": datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat()})
Defensive patterns

Strategy: validation

Validate before calling

from datetime import datetime, timezone
for key in ("createdAt", "updatedAt"):
    v = fact.get(key)
    if v is not None and not isinstance(v, str):
        fact[key] = datetime.fromtimestamp(v, tz=timezone.utc).isoformat() if isinstance(v, (int, float)) else str(v)

Type guard

def has_string_timestamps(fact: dict) -> bool:
    return isinstance(fact.get("createdAt", ""), str) and isinstance(fact.get("updatedAt", ""), str)

Prevention

When it happens

Trigger: Saving {'createdAt': 1710000000} (epoch int) or {'createdAt': None} on the incoming dict while an existing record also lacks valid timestamps; imports using datetime objects rather than their ISO strings.

Common situations: Producers serializing datetimes as epoch numbers; ORM-style records leaking datetime objects; hand-built facts copying timestamps from another system's numeric format.

Related errors


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

Appendix: source

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

        normalized["revision"] = revision
    else:
        existing_revision = existing.get("revision")
        if not isinstance(existing_revision, int) or existing_revision < 1:
            raise MemoryStorageCorruption(f"Stored fact {normalized['id']!r} has an invalid revision")
        if revision != existing_revision:
            raise MemoryFactRevisionConflict(f"Expected fact {normalized['id']!r} revision {revision}, found {existing_revision}")
        normalized["createdAt"] = existing.get("createdAt") or normalized.get("createdAt") or now
        comparison_keys = {"revision", "updatedAt"}
        incoming_material = {key: value for key, value in normalized.items() if key not in comparison_keys}
        existing_material = {key: value for key, value in existing.items() if key not in comparison_keys}
        if incoming_material == existing_material:
            normalized["revision"] = existing_revision
            normalized["updatedAt"] = existing.get("updatedAt") or normalized["createdAt"]
        else:
            normalized["revision"] = existing_revision + 1
            normalized["updatedAt"] = now
    if not isinstance(normalized.get("createdAt"), str) or not isinstance(normalized.get("updatedAt"), str):
        raise ValueError("fact.createdAt and fact.updatedAt must be strings")
    if normalized["consolidatedFrom"]:
        normalized.setdefault("consolidatedAt", normalized["updatedAt"])
    return normalized


def _safe_relative_path(root: Path, relative: str, *, label: str) -> Path:
    """Resolve an untrusted persisted relative path without leaving root."""
    candidate = Path(relative)
    if candidate.is_absolute():
        raise MemoryStorageCorruption(f"{label} path escapes the user memory directory: {relative!r}")
    root_resolved = root.resolve()
    resolved = (root / candidate).resolve()
    try:
        resolved.relative_to(root_resolved)
    except ValueError as exc:
        raise MemoryStorageCorruption(f"{label} path escapes the user memory directory: {relative!r}") from exc
    return resolved

View on GitHub (pinned to 1dd6ba1acb)