"""Canonical thread identifier validation shared across DeerFlow backends.""" from __future__ import annotations import re import uuid from typing import Annotated from pydantic import AfterValidator, StringConstraints from deerflow.utils.host_paths import windows_incompatible_segment THREAD_ID_PATTERN = r"^[A-Za-z0-9_-]{1,64}$" _THREAD_ID_RE = re.compile(THREAD_ID_PATTERN) def validate_thread_id(thread_id: str) -> str: """Return a valid thread ID or raise ``ValueError``. Thread IDs are caller-defined opaque identifiers, not necessarily UUIDs, but they must be safe for every persistence and filesystem backend. """ if not isinstance(thread_id, str) or _THREAD_ID_RE.fullmatch(thread_id) is None: raise ValueError("Invalid thread_id: expected 1-64 ASCII letters, digits, hyphens, or underscores") # Windows reserves device names such as NUL in every directory; creating a # thread workspace named "NUL" fails with FileNotFoundError on Windows # hosts. Reuse the canonical Windows-name check that uploads, sandbox # writes and skill support files already go through, so thread IDs follow # the same documented "rejected on every platform when a name is created" # contract instead of keeping a second name list in sync. reason = windows_incompatible_segment(thread_id) if reason is not None: raise ValueError(f"Invalid thread_id {thread_id!r}: {reason}") return thread_id def resolve_thread_id(thread_id: str | None) -> str: """Validate a supplied ID, generating a UUID only when it is ``None``.""" if thread_id is None: return str(uuid.uuid4()) return validate_thread_id(thread_id) ThreadId = Annotated[ str, StringConstraints(min_length=1, max_length=64, pattern=THREAD_ID_PATTERN), AfterValidator(validate_thread_id), ]