"""Thread CRUD, state, and history endpoints. Combines the existing thread-local filesystem cleanup with LangGraph Platform-compatible thread management backed by the checkpointer. Channel values returned in state responses are serialized through :func:`deerflow.runtime.serialization.serialize_channel_values` to ensure LangChain message objects are converted to JSON-safe dicts matching the LangGraph Platform wire format expected by the ``useStream`` React hook. """ from __future__ import annotations import copy import logging import shutil import uuid from pathlib import Path from typing import Any from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from langgraph.checkpoint.base import empty_checkpoint, uuid6 from pydantic import BaseModel, Field, field_validator from sqlalchemy.exc import IntegrityError from app.gateway.authz import require_permission from app.gateway.deps import get_checkpointer, get_run_manager from app.gateway.internal_auth import get_trusted_internal_owner_user_id from app.gateway.utils import sanitize_log_param from deerflow.config.paths import Paths, get_paths from deerflow.config.summarization_config import ContextSize from deerflow.runtime import serialize_channel_values_for_api from deerflow.runtime.context_compaction import ( ContextCompactionDisabled, ContextCompactionFailed, ThreadCompactionResult, compact_thread_context, ) from deerflow.runtime.goal import ( DEFAULT_MAX_GOAL_CONTINUATIONS, build_goal_state, ensure_thread_checkpoint, goal_thread_lock, read_thread_goal, write_thread_goal, ) from deerflow.runtime.runs.worker import valid_duration_entry from deerflow.runtime.user_context import get_effective_user_id from deerflow.utils.file_io import run_file_io from deerflow.utils.time import coerce_iso, now_iso logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/threads", tags=["threads"]) # Metadata keys that the server controls; clients are not allowed to set # them. Pydantic ``@field_validator("metadata")`` strips them on every # inbound model below so a malicious client cannot reflect a forged # owner identity through the API surface. Defense-in-depth — the # row-level invariant is still ``threads_meta.user_id`` populated from # the auth contextvar; this list closes the metadata-blob echo gap. _SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"}) _SIDECAR_METADATA_KEY = "deerflow_sidecar" _BRANCH_METADATA_KEY = "deerflow_branch" _BRANCH_HISTORY_SCAN_LIMIT = 200 def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]: """Return ``metadata`` with server-controlled keys removed.""" if not metadata: return metadata or {} return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS} def _message_id(message: Any) -> str | None: if isinstance(message, dict): raw = message.get("id") else: raw = getattr(message, "id", None) return raw if isinstance(raw, str) and raw else None def _message_type(message: Any) -> str | None: if isinstance(message, dict): raw = message.get("type") else: raw = getattr(message, "type", None) return raw if isinstance(raw, str) and raw else None def _message_additional_kwargs(message: Any) -> dict[str, Any]: if isinstance(message, dict): raw = message.get("additional_kwargs") else: raw = getattr(message, "additional_kwargs", None) return raw if isinstance(raw, dict) else {} def _is_branch_visible_message(message: Any) -> bool: if _message_additional_kwargs(message).get("hide_from_ui") is True: return False return _message_type(message) in {"human", "ai"} def _is_branch_assistant_message(message: Any) -> bool: return _message_type(message) == "ai" def _checkpoint_messages(checkpoint_tuple: Any) -> list[Any]: checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {} channel_values = checkpoint.get("channel_values", {}) or {} messages = channel_values.get("messages") or [] return list(messages) if isinstance(messages, list) else [] def _checkpoint_id(checkpoint_tuple: Any) -> str | None: config = getattr(checkpoint_tuple, "config", {}) or {} raw = config.get("configurable", {}).get("checkpoint_id") return raw if isinstance(raw, str) and raw else None def _matches_branch_target(messages: list[Any], target_message_ids: set[str]) -> bool: if not target_message_ids: return False index_by_id = {_message_id(message): index for index, message in enumerate(messages) if _message_id(message)} if not target_message_ids.issubset(index_by_id.keys()): return False if any(not _is_branch_assistant_message(messages[index_by_id[message_id]]) for message_id in target_message_ids): return False target_end_index = max(index_by_id[message_id] for message_id in target_message_ids) return not any(_is_branch_visible_message(message) for message in messages[target_end_index + 1 :]) async def _find_branch_checkpoint(checkpointer: Any, thread_id: str, target_message_ids: set[str]) -> Any: config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} try: async for checkpoint_tuple in checkpointer.alist(config, limit=_BRANCH_HISTORY_SCAN_LIMIT): if _matches_branch_target(_checkpoint_messages(checkpoint_tuple), target_message_ids): return checkpoint_tuple except Exception: logger.exception("Failed to scan branch checkpoint history for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to find branch checkpoint") raise HTTPException(status_code=409, detail="This turn can no longer be branched from.") async def _branch_targets_latest_turn(checkpointer: Any, thread_id: str, target_message_ids: set[str]) -> bool: """Return True when the target turn is the final visible turn in the current state. ``alist`` yields newest-first; we take the newest checkpoint that actually holds messages (thread creation writes an empty checkpoint that must be skipped) and reuse ``_matches_branch_target`` to check the target turn is its tail. Used to decide whether cloning the (uncheckpointed) workspace onto a branch is safe: only a branch from the latest turn shares the current workspace timeline. On any lookup failure we fail closed (treat as historical) so a branch from an older turn never inherits a later timeline's workspace files. """ config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} try: async for checkpoint_tuple in checkpointer.alist(config, limit=_BRANCH_HISTORY_SCAN_LIMIT): messages = _checkpoint_messages(checkpoint_tuple) if not messages: continue return _matches_branch_target(messages, target_message_ids) except Exception: logger.warning( "Failed to resolve latest turn for thread %s; treating branch as historical", sanitize_log_param(thread_id), exc_info=True, ) return False def _ignore_branch_user_data(directory: str, names: list[str]) -> set[str]: ignored: set[str] = set() base = Path(directory) for name in names: path = base / name if name.startswith(".upload-") and name.endswith(".part"): ignored.add(name) elif path.is_symlink(): ignored.add(name) return ignored def _copy_branch_user_data_sync(paths: Paths, source_thread_id: str, target_thread_id: str, *, user_id: str) -> str: source = paths.sandbox_user_data_dir(source_thread_id, user_id=user_id) target = paths.sandbox_user_data_dir(target_thread_id, user_id=user_id) if not source.exists(): return "not_found" shutil.copytree(source, target, ignore=_ignore_branch_user_data, dirs_exist_ok=True) return "current_thread_best_effort" async def _copy_branch_user_data(source_thread_id: str, target_thread_id: str) -> str: paths = get_paths() user_id = get_effective_user_id() try: return await run_file_io(_copy_branch_user_data_sync, paths, source_thread_id, target_thread_id, user_id=user_id) except Exception: logger.warning( "Failed to copy user-data for branch %s -> %s", sanitize_log_param(source_thread_id), sanitize_log_param(target_thread_id), exc_info=True, ) return "failed" def _default_branch_display_name(source_title: Any, *, source_is_branch: bool = False) -> str | None: if not isinstance(source_title, str): return None display_name = source_title.strip() if source_is_branch: while display_name.lower().startswith("branch:"): display_name = display_name[len("branch:") :].strip() return display_name or None # --------------------------------------------------------------------------- # Response / request models # --------------------------------------------------------------------------- class ThreadDeleteResponse(BaseModel): """Response model for thread cleanup.""" success: bool message: str class ThreadResponse(BaseModel): """Response model for a single thread.""" thread_id: str = Field(description="Unique thread identifier") status: str = Field(default="idle", description="Thread status: idle, busy, interrupted, error") created_at: str = Field(default="", description="ISO timestamp") updated_at: str = Field(default="", description="ISO timestamp") metadata: dict[str, Any] = Field(default_factory=dict, description="Thread metadata") values: dict[str, Any] = Field(default_factory=dict, description="Current state channel values") interrupts: dict[str, Any] = Field(default_factory=dict, description="Pending interrupts") class ThreadCreateRequest(BaseModel): """Request body for creating a thread.""" thread_id: str | None = Field(default=None, description="Optional thread ID (auto-generated if omitted)") assistant_id: str | None = Field(default=None, description="Associate thread with an assistant") metadata: dict[str, Any] = Field(default_factory=dict, description="Initial metadata") _strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v))) class ThreadSearchRequest(BaseModel): """Request body for searching threads.""" metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata filter (exact match)") limit: int = Field(default=100, ge=1, le=1000, description="Maximum results") offset: int = Field(default=0, ge=0, description="Pagination offset") status: str | None = Field(default=None, description="Filter by thread status") @field_validator("metadata") @classmethod def _validate_metadata_filters(cls, v: dict[str, Any]) -> dict[str, Any]: """Reject filter entries the SQL backend cannot compile. Enforces consistent behaviour across SQL and memory backends. See ``deerflow.persistence.json_compat`` for the shared validators. """ if not v: return v from deerflow.persistence.json_compat import validate_metadata_filter_key, validate_metadata_filter_value bad_entries: list[str] = [] for key, value in v.items(): if not validate_metadata_filter_key(key): bad_entries.append(f"{key!r} (unsafe key)") elif not validate_metadata_filter_value(value): bad_entries.append(f"{key!r} (unsupported value type {type(value).__name__})") if bad_entries: raise ValueError(f"Invalid metadata filter entries: {', '.join(bad_entries)}") return v class ThreadStateResponse(BaseModel): """Response model for thread state.""" values: dict[str, Any] = Field(default_factory=dict, description="Current channel values") next: list[str] = Field(default_factory=list, description="Next tasks to execute") metadata: dict[str, Any] = Field(default_factory=dict, description="Checkpoint metadata") checkpoint: dict[str, Any] = Field(default_factory=dict, description="Checkpoint info") checkpoint_id: str | None = Field(default=None, description="Current checkpoint ID") parent_checkpoint_id: str | None = Field(default=None, description="Parent checkpoint ID") created_at: str | None = Field(default=None, description="Checkpoint timestamp") tasks: list[dict[str, Any]] = Field(default_factory=list, description="Interrupted task details") class ThreadPatchRequest(BaseModel): """Request body for patching thread metadata.""" metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata to merge") _strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v))) class ThreadStateUpdateRequest(BaseModel): """Request body for updating thread state (human-in-the-loop resume).""" values: dict[str, Any] | None = Field(default=None, description="Channel values to merge") checkpoint_id: str | None = Field(default=None, description="Checkpoint to branch from") checkpoint: dict[str, Any] | None = Field(default=None, description="Full checkpoint object") as_node: str | None = Field(default=None, description="Node identity for the update") class ThreadGoalRequest(BaseModel): """Request body for setting a thread-scoped goal.""" objective: str = Field(..., min_length=1, max_length=4000, description="Completion condition for the agent to keep pursuing") max_continuations: int = Field( default=DEFAULT_MAX_GOAL_CONTINUATIONS, ge=0, le=DEFAULT_MAX_GOAL_CONTINUATIONS, description="Maximum automatic hidden continuation turns before stopping", ) class ThreadGoalResponse(BaseModel): """Response model for a thread goal.""" goal: dict[str, Any] | None = Field(default=None, description="Current goal state, or null when no goal is active") class ThreadCompactRequest(BaseModel): """Request body for manually compacting a thread's active context.""" force: bool = Field(default=True, description="Run compaction even if automatic summarization thresholds are not met") keep: ContextSize | None = Field(default=None, description="Optional retention policy for this compaction only") agent_name: str | None = Field(default=None, max_length=128, description="Optional custom agent name for memory attribution") class ThreadCompactResponse(BaseModel): """Response model for manual thread-context compaction.""" thread_id: str compacted: bool reason: str | None = None removed_message_count: int = 0 preserved_message_count: int = 0 summary_updated: bool = False checkpoint_id: str | None = None total_tokens: int = 0 class HistoryEntry(BaseModel): """Single checkpoint history entry.""" checkpoint_id: str parent_checkpoint_id: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) values: dict[str, Any] = Field(default_factory=dict) created_at: str | None = None next: list[str] = Field(default_factory=list) class ThreadHistoryRequest(BaseModel): """Request body for checkpoint history.""" limit: int = Field(default=10, ge=1, le=100, description="Maximum entries") before: str | None = Field(default=None, description="Cursor for pagination") class ThreadBranchRequest(BaseModel): """Request body for creating a branch from a completed assistant turn.""" message_id: str = Field(..., min_length=1, description="Target assistant message ID to branch from") message_ids: list[str] = Field(default_factory=list, description="All assistant message IDs in the target turn") title: str | None = Field(default=None, max_length=256, description="Optional title for the branched thread") class ThreadBranchResponse(BaseModel): """Response model for a thread branch.""" thread_id: str parent_thread_id: str parent_checkpoint_id: str branched_from_message_id: str workspace_clone_mode: str # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _delete_thread_data(thread_id: str, paths: Paths | None = None, *, user_id: str | None = None) -> ThreadDeleteResponse: """Delete local persisted filesystem data for a thread.""" path_manager = paths or get_paths() try: path_manager.delete_thread_dir(thread_id, user_id=user_id) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except FileNotFoundError: # Not critical — thread data may not exist on disk logger.debug("No local thread data to delete for %s", sanitize_log_param(thread_id)) return ThreadDeleteResponse(success=True, message=f"No local data for {thread_id}") except Exception as exc: logger.exception("Failed to delete thread data for %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to delete local thread data.") from exc logger.info("Deleted local thread data for %s", sanitize_log_param(thread_id)) return ThreadDeleteResponse(success=True, message=f"Deleted local thread data for {thread_id}") def _derive_thread_status(checkpoint_tuple) -> str: """Derive thread status from checkpoint metadata.""" if checkpoint_tuple is None: return "idle" pending_writes = getattr(checkpoint_tuple, "pending_writes", None) or [] # Check for error in pending writes for pw in pending_writes: if len(pw) >= 2 and pw[1] == "__error__": return "error" # Check for pending next tasks (indicates interrupt) tasks = getattr(checkpoint_tuple, "tasks", None) if tasks: return "interrupted" return "idle" async def _ensure_thread_for_goal(thread_id: str, request: Request) -> None: """Ensure a thread_meta row and root checkpoint exist for goal commands.""" from app.gateway.deps import get_thread_store thread_store = get_thread_store(request) checkpointer = get_checkpointer(request) thread_owner_user_id = get_trusted_internal_owner_user_id(request) thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {} record = await thread_store.get(thread_id, **thread_owner_kwargs) if record is None and thread_owner_user_id: unscoped_record = await thread_store.get(thread_id, user_id=None) if unscoped_record is not None: if unscoped_record.get("user_id") != thread_owner_user_id: await thread_store.update_owner(thread_id, thread_owner_user_id, user_id=None) record = await thread_store.get(thread_id, **thread_owner_kwargs) if record is None: try: await thread_store.create(thread_id, metadata={}, **thread_owner_kwargs) except Exception: logger.exception("Failed to create thread_meta for goal thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to create thread") from None try: await ensure_thread_checkpoint(checkpointer, thread_id) except Exception: logger.exception("Failed to create goal checkpoint for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to create thread checkpoint") from None # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.delete("/{thread_id}", response_model=ThreadDeleteResponse) @require_permission("threads", "delete", owner_check=True, require_existing=True) async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteResponse: """Delete local persisted filesystem data for a thread. Cleans DeerFlow-managed thread directories, removes checkpoint data, and removes the thread_meta row from the configured ThreadMetaStore (sqlite or memory). """ from app.gateway.deps import get_thread_store # Clean local filesystem response = _delete_thread_data(thread_id, user_id=get_effective_user_id()) # Remove checkpoints (best-effort) checkpointer = getattr(request.app.state, "checkpointer", None) if checkpointer is not None: try: if hasattr(checkpointer, "adelete_thread"): await checkpointer.adelete_thread(thread_id) except Exception: logger.debug("Could not delete checkpoints for thread %s (not critical)", sanitize_log_param(thread_id)) # Remove thread_meta row (best-effort) — required for sqlite backend # so the deleted thread no longer appears in /threads/search. try: thread_store = get_thread_store(request) await thread_store.delete(thread_id) except Exception: logger.debug("Could not delete thread_meta for %s (not critical)", sanitize_log_param(thread_id)) return response async def _resolve_existing_thread( thread_store: Any, thread_id: str, thread_owner_user_id: str | None, thread_owner_kwargs: dict[str, Any], ) -> dict | None: """Return the existing thread_meta record for an idempotent create. When the caller carries a trusted internal owner but only a legacy unscoped (``user_id=None``) row exists, claim it for that owner before returning. Both the fast path and the insert-race recovery path resolve through here so a thread's ownership does not diverge based on which path found the record. """ existing_record = await thread_store.get(thread_id, **thread_owner_kwargs) if existing_record is None and thread_owner_user_id: unscoped_record = await thread_store.get(thread_id, user_id=None) if unscoped_record is not None: if unscoped_record.get("user_id") != thread_owner_user_id: await thread_store.update_owner(thread_id, thread_owner_user_id, user_id=None) existing_record = await thread_store.get(thread_id, **thread_owner_kwargs) return existing_record def _existing_thread_response(thread_id: str, record: dict) -> ThreadResponse: return ThreadResponse( thread_id=thread_id, status=record.get("status", "idle"), created_at=coerce_iso(record.get("created_at", "")), updated_at=coerce_iso(record.get("updated_at", "")), metadata=record.get("metadata", {}), ) @router.post("", response_model=ThreadResponse) async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadResponse: """Create a new thread. Writes a thread_meta record (so the thread appears in /threads/search) and an empty checkpoint (so state endpoints work immediately). Idempotent: returns the existing record when ``thread_id`` already exists. """ from app.gateway.deps import get_thread_store checkpointer = get_checkpointer(request) thread_store = get_thread_store(request) thread_id = body.thread_id or str(uuid.uuid4()) now = now_iso() thread_owner_user_id = get_trusted_internal_owner_user_id(request) thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {} # ``body.metadata`` is already stripped of server-reserved keys by # ``ThreadCreateRequest._strip_reserved`` — see the model definition. # Idempotency: return existing record when already present existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs) if existing_record is not None: return _existing_thread_response(thread_id, existing_record) # Write thread_meta so the thread appears in /threads/search immediately try: await thread_store.create( thread_id, assistant_id=getattr(body, "assistant_id", None), **thread_owner_kwargs, metadata=body.metadata, ) except IntegrityError: # The idempotency read above and this insert are not atomic: a # concurrent request for the same thread_id can commit in between, so # the SQL-backed store rejects ours on the duplicate primary key. # Honour the documented idempotency contract by resolving the # now-existing record — running the same owner reconciliation the fast # path does — instead of surfacing the conflict as a 500. (The memory # store overwrites rather than raising, so it never reaches here.) existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs) if existing_record is not None: return _existing_thread_response(thread_id, existing_record) # A duplicate-key error with no row we can read back is a real failure. logger.exception("Failed to write thread_meta for %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to create thread") except Exception: # Any non-race failure must surface, not be silently swallowed as a 200. logger.exception("Failed to write thread_meta for %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to create thread") # Write an empty checkpoint so state endpoints work immediately config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} try: ckpt_metadata = { "step": -1, "source": "input", "writes": None, "parents": {}, **body.metadata, "created_at": now, } await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {}) except Exception: logger.exception("Failed to create checkpoint for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to create thread") logger.info("Thread created: %s", sanitize_log_param(thread_id)) return ThreadResponse( thread_id=thread_id, status="idle", created_at=now, updated_at=now, metadata=body.metadata, ) @router.post("/{thread_id}/branches", response_model=ThreadBranchResponse) @require_permission("threads", "write", owner_check=True, require_existing=True) async def branch_thread(thread_id: str, body: ThreadBranchRequest, request: Request) -> ThreadBranchResponse: """Create a new main-thread branch from a completed assistant turn.""" from app.gateway.deps import get_thread_store checkpointer = get_checkpointer(request) thread_store = get_thread_store(request) source_record = await thread_store.get(thread_id) if source_record is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") source_metadata = source_record.get("metadata") or {} if source_metadata.get(_SIDECAR_METADATA_KEY) is True: raise HTTPException(status_code=409, detail="Branching is only available in the main conversation.") target_message_ids = {body.message_id, *body.message_ids} checkpoint_tuple = await _find_branch_checkpoint(checkpointer, thread_id, target_message_ids) parent_checkpoint_id = _checkpoint_id(checkpoint_tuple) if not parent_checkpoint_id: raise HTTPException(status_code=409, detail="This turn can no longer be branched from.") # Workspace files are not checkpointed, so they only reflect the *current* thread # state. Cloning them onto a branch from an older turn would leak files created # after that turn (message history rolls back, workspace would not). Restrict the # best-effort clone to branches taken from the latest turn so history and workspace # stay consistent. branch_from_latest_turn = await _branch_targets_latest_turn(checkpointer, thread_id, target_message_ids) new_thread_id = str(uuid.uuid4()) now = now_iso() branch_metadata = { _BRANCH_METADATA_KEY: True, "branch_parent_thread_id": thread_id, "branch_parent_checkpoint_id": parent_checkpoint_id, "branch_parent_message_id": body.message_id, "branch_created_at": now, } display_name = body.title or _default_branch_display_name( source_record.get("display_name"), source_is_branch=source_metadata.get(_BRANCH_METADATA_KEY) is True, ) thread_owner_user_id = get_trusted_internal_owner_user_id(request) thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {} checkpoint = copy.deepcopy(getattr(checkpoint_tuple, "checkpoint", {}) or {}) metadata = copy.deepcopy(getattr(checkpoint_tuple, "metadata", {}) or {}) checkpoint["id"] = str(uuid6()) metadata.update( { "source": "branch", "updated_at": now, "created_at": now, **branch_metadata, } ) write_config = {"configurable": {"thread_id": new_thread_id, "checkpoint_ns": ""}} new_versions = dict(checkpoint.get("channel_versions", {}) or {}) try: await checkpointer.aput(write_config, checkpoint, metadata, new_versions) except Exception: logger.exception("Failed to write branch checkpoint for thread %s", sanitize_log_param(new_thread_id)) raise HTTPException(status_code=500, detail="Failed to create branch") from None try: await thread_store.create( new_thread_id, assistant_id=source_record.get("assistant_id"), display_name=display_name, metadata=branch_metadata, **thread_owner_kwargs, ) except Exception: logger.exception("Failed to write branch thread_meta for %s", sanitize_log_param(new_thread_id)) raise HTTPException(status_code=500, detail="Failed to create branch") from None if branch_from_latest_turn: workspace_clone_mode = await _copy_branch_user_data(thread_id, new_thread_id) else: workspace_clone_mode = "skipped_historical_turn" return ThreadBranchResponse( thread_id=new_thread_id, parent_thread_id=thread_id, parent_checkpoint_id=parent_checkpoint_id, branched_from_message_id=body.message_id, workspace_clone_mode=workspace_clone_mode, ) @router.post("/search", response_model=list[ThreadResponse]) async def search_threads(body: ThreadSearchRequest, request: Request) -> list[ThreadResponse]: """Search and list threads. Delegates to the configured ThreadMetaStore implementation (SQL-backed for sqlite/postgres, Store-backed for memory mode). """ from app.gateway.deps import get_thread_store from deerflow.persistence.thread_meta import InvalidMetadataFilterError repo = get_thread_store(request) try: rows = await repo.search( metadata=body.metadata or None, status=body.status, limit=body.limit, offset=body.offset, ) except InvalidMetadataFilterError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return [ ThreadResponse( thread_id=r["thread_id"], status=r.get("status", "idle"), # ``coerce_iso`` heals legacy unix-second values that # ``MemoryThreadMetaStore`` historically wrote with ``time.time()``; # SQL-backed rows already arrive as ISO strings and pass through. created_at=coerce_iso(r.get("created_at", "")), updated_at=coerce_iso(r.get("updated_at", "")), metadata=r.get("metadata", {}), values={"title": r["display_name"]} if r.get("display_name") else {}, interrupts={}, ) for r in rows ] @router.patch("/{thread_id}", response_model=ThreadResponse) @require_permission("threads", "write", owner_check=True, require_existing=True) async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Request) -> ThreadResponse: """Merge metadata into a thread record.""" from app.gateway.deps import get_thread_store thread_store = get_thread_store(request) record = await thread_store.get(thread_id) if record is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") # ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``. try: await thread_store.update_metadata(thread_id, body.metadata) except Exception: logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to update thread") # Re-read to get the merged metadata + refreshed updated_at record = await thread_store.get(thread_id) or record return ThreadResponse( thread_id=thread_id, status=record.get("status", "idle"), created_at=coerce_iso(record.get("created_at", "")), updated_at=coerce_iso(record.get("updated_at", "")), metadata=record.get("metadata", {}), ) @router.get("/{thread_id}", response_model=ThreadResponse) @require_permission("threads", "read", owner_check=True) async def get_thread(thread_id: str, request: Request) -> ThreadResponse: """Get thread info. Reads metadata from the ThreadMetaStore and derives the accurate execution status from the checkpointer. Falls back to the checkpointer alone for threads that pre-date ThreadMetaStore adoption (backward compat). """ from app.gateway.deps import get_thread_store thread_store = get_thread_store(request) checkpointer = get_checkpointer(request) record: dict | None = await thread_store.get(thread_id) # Derive accurate status from the checkpointer config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} try: checkpoint_tuple = await checkpointer.aget_tuple(config) except Exception: logger.exception("Failed to get checkpoint for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to get thread") if record is None and checkpoint_tuple is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") # If the thread exists in the checkpointer but not in thread_meta (e.g. # legacy data created before thread_meta adoption), synthesize a minimal # record from the checkpoint metadata. if record is None and checkpoint_tuple is not None: ckpt_meta = getattr(checkpoint_tuple, "metadata", {}) or {} record = { "thread_id": thread_id, "status": "idle", "created_at": coerce_iso(ckpt_meta.get("created_at", "")), "updated_at": coerce_iso(ckpt_meta.get("updated_at", ckpt_meta.get("created_at", ""))), "metadata": {k: v for k, v in ckpt_meta.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents")}, } if record is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") status = _derive_thread_status(checkpoint_tuple) if checkpoint_tuple is not None else record.get("status", "idle") checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {} if checkpoint_tuple is not None else {} channel_values = checkpoint.get("channel_values", {}) return ThreadResponse( thread_id=thread_id, status=status, created_at=coerce_iso(record.get("created_at", "")), updated_at=coerce_iso(record.get("updated_at", "")), metadata=record.get("metadata", {}), values=serialize_channel_values_for_api(channel_values), ) @router.get("/{thread_id}/goal", response_model=ThreadGoalResponse) @require_permission("threads", "read", owner_check=True) async def get_thread_goal(thread_id: str, request: Request) -> ThreadGoalResponse: """Return the active Claude-style goal for a thread, if any.""" checkpointer = get_checkpointer(request) try: goal = await read_thread_goal(checkpointer, thread_id) except Exception: logger.exception("Failed to read goal for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to read thread goal") from None return ThreadGoalResponse(goal=goal) @router.put("/{thread_id}/goal", response_model=ThreadGoalResponse) @require_permission("threads", "write", owner_check=True) async def set_thread_goal(thread_id: str, body: ThreadGoalRequest, request: Request) -> ThreadGoalResponse: """Set or replace the active goal for a thread. ``/chats/new`` pages already hold a generated UUID before the first run, so this endpoint creates the missing thread checkpoint on demand. """ checkpointer = get_checkpointer(request) await _ensure_thread_for_goal(thread_id, request) try: goal = build_goal_state(body.objective, max_continuations=body.max_continuations) async with goal_thread_lock(thread_id): await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", create_if_missing=True) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except Exception: logger.exception("Failed to set goal for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to set thread goal") from None return ThreadGoalResponse(goal=goal) @router.delete("/{thread_id}/goal", response_model=ThreadGoalResponse) @require_permission("threads", "write", owner_check=True) async def clear_thread_goal(thread_id: str, request: Request) -> ThreadGoalResponse: """Clear the active goal for a thread.""" checkpointer = get_checkpointer(request) try: async with goal_thread_lock(thread_id): await write_thread_goal(checkpointer, thread_id, None, as_node="goal") except LookupError: return ThreadGoalResponse(goal=None) except Exception: logger.exception("Failed to clear goal for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to clear thread goal") from None return ThreadGoalResponse(goal=None) def _thread_compact_response(result: ThreadCompactionResult) -> ThreadCompactResponse: return ThreadCompactResponse( thread_id=result.thread_id, compacted=result.compacted, reason=result.reason, removed_message_count=result.removed_message_count, preserved_message_count=result.preserved_message_count, summary_updated=result.summary_updated, checkpoint_id=result.checkpoint_id, total_tokens=result.total_tokens, ) @router.post("/{thread_id}/compact", response_model=ThreadCompactResponse) @require_permission("threads", "write", owner_check=True, require_existing=True) async def compact_thread(thread_id: str, body: ThreadCompactRequest, request: Request) -> ThreadCompactResponse: """Manually summarize old thread context while preserving the visible history.""" run_manager = get_run_manager(request) checkpointer = get_checkpointer(request) keep = body.keep.to_tuple() if body.keep is not None else None try: async with goal_thread_lock(thread_id): if await run_manager.has_inflight(thread_id): raise HTTPException(status_code=409, detail="Thread has a run in flight. Compact after the run finishes.") result = await compact_thread_context( checkpointer, thread_id, keep=keep, force=body.force, user_id=get_effective_user_id(), agent_name=body.agent_name, ) except ContextCompactionDisabled: raise HTTPException(status_code=409, detail="Context compaction is disabled.") from None except ContextCompactionFailed: raise HTTPException(status_code=500, detail="Failed to compact thread context.") from None except LookupError: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") from None except HTTPException: raise except Exception: logger.exception("Failed to compact thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to compact thread context.") from None return _thread_compact_response(result) # --------------------------------------------------------------------------- @router.get("/{thread_id}/state", response_model=ThreadStateResponse) @require_permission("threads", "read", owner_check=True) async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse: """Get the latest state snapshot for a thread. Channel values are serialized to ensure LangChain message objects are converted to JSON-safe dicts. """ checkpointer = get_checkpointer(request) config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} try: checkpoint_tuple = await checkpointer.aget_tuple(config) except Exception: logger.exception("Failed to get state for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to get thread state") if checkpoint_tuple is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {} metadata = getattr(checkpoint_tuple, "metadata", {}) or {} checkpoint_id = None ckpt_config = getattr(checkpoint_tuple, "config", {}) if ckpt_config: checkpoint_id = ckpt_config.get("configurable", {}).get("checkpoint_id") channel_values = checkpoint.get("channel_values", {}) parent_config = getattr(checkpoint_tuple, "parent_config", None) parent_checkpoint_id = None if parent_config: parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id") tasks_raw = getattr(checkpoint_tuple, "tasks", []) or [] next_tasks = [t.name for t in tasks_raw if hasattr(t, "name")] tasks = [{"id": getattr(t, "id", ""), "name": getattr(t, "name", "")} for t in tasks_raw] values = serialize_channel_values_for_api(channel_values) return ThreadStateResponse( values=values, next=next_tasks, metadata=metadata, checkpoint={"id": checkpoint_id, "ts": coerce_iso(metadata.get("created_at", ""))}, checkpoint_id=checkpoint_id, parent_checkpoint_id=parent_checkpoint_id, created_at=coerce_iso(metadata.get("created_at", "")), tasks=tasks, ) @router.post("/{thread_id}/state", response_model=ThreadStateResponse) @require_permission("threads", "write", owner_check=True, require_existing=True) async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse: """Update thread state (e.g. for human-in-the-loop resume or title rename). Writes a new checkpoint that merges *body.values* into the latest channel values, then syncs any updated ``title`` field through the ThreadMetaStore abstraction so that ``/threads/search`` reflects the change immediately in both sqlite and memory backends. """ from app.gateway.deps import get_thread_store checkpointer = get_checkpointer(request) thread_store = get_thread_store(request) # checkpoint_ns must be present in the config for aput — default to "" # (the root graph namespace). checkpoint_id is optional; omitting it # fetches the latest checkpoint for the thread. read_config: dict[str, Any] = { "configurable": { "thread_id": thread_id, "checkpoint_ns": "", } } if body.checkpoint_id: read_config["configurable"]["checkpoint_id"] = body.checkpoint_id try: checkpoint_tuple = await checkpointer.aget_tuple(read_config) except Exception: logger.exception("Failed to get state for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to get thread state") if checkpoint_tuple is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") # Work on mutable copies so we don't accidentally mutate cached objects. checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {}) metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {}) channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {})) if body.values: channel_values.update(body.values) checkpoint["channel_values"] = channel_values metadata["updated_at"] = now_iso() if body.as_node: metadata["source"] = "update" metadata["step"] = metadata.get("step", 0) + 1 metadata["writes"] = {body.as_node: body.values} # Assign a new checkpoint ID so aput performs an INSERT rather than an # in-place REPLACE of the existing row. Use uuid6 (time-ordered) rather # than uuid4 (random) so the new ID is always lexicographically greater # than the previous one — LangGraph's checkpointers determine the "latest" # checkpoint by max(checkpoint_ids) string order, matching the uuid6 epoch. checkpoint["id"] = str(uuid6()) # aput requires checkpoint_ns in the config — use the same config used for the # read (which always includes checkpoint_ns=""). The fresh checkpoint ID is # assigned above via checkpoint["id"]; keep checkpoint_id out of the config so # the write is keyed by the new checkpoint payload rather than the prior read. # All supported savers (InMemorySaver, AsyncSqliteSaver, AsyncPostgresSaver) # persist and echo back checkpoint["id"] verbatim — none mint their own — so # the new_config below carries the uuid6 we assigned here. (Regression-locked # by test_update_thread_state_inserts_new_checkpoint_each_call.) write_config: dict[str, Any] = { "configurable": { "thread_id": thread_id, "checkpoint_ns": "", } } try: new_config = await checkpointer.aput(write_config, checkpoint, metadata, {}) except Exception: logger.exception("Failed to update state for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to update thread state") new_checkpoint_id: str | None = None if isinstance(new_config, dict): new_checkpoint_id = new_config.get("configurable", {}).get("checkpoint_id") # Sync title changes through the ThreadMetaStore abstraction so /threads/search # reflects them immediately in both sqlite and memory backends. if thread_store and body.values and "title" in body.values: new_title = body.values["title"] if new_title: # Skip empty strings and None try: await thread_store.update_display_name(thread_id, new_title) except Exception: logger.debug("Failed to sync title to thread_meta for %s (non-fatal)", sanitize_log_param(thread_id)) return ThreadStateResponse( values=serialize_channel_values_for_api(channel_values), next=[], metadata=metadata, checkpoint_id=new_checkpoint_id, created_at=coerce_iso(metadata.get("created_at", "")), ) def _ai_message_lacks_duration(message: dict[str, Any]) -> bool: additional_kwargs = message.get("additional_kwargs") return message.get("type") == "ai" and (not isinstance(additional_kwargs, dict) or "turn_duration" not in additional_kwargs) def _checkpoint_run_durations(metadata: Any) -> dict[str, int]: raw_durations = metadata.get("run_durations") if isinstance(metadata, dict) else None if not isinstance(raw_durations, dict): return {} return {run_id: duration_seconds for run_id, duration_seconds in raw_durations.items() if valid_duration_entry(run_id, duration_seconds)} def _set_message_turn_duration(message: dict[str, Any], run_id: str, run_durations: dict[str, int]) -> None: if message.get("type") != "ai" or run_id not in run_durations: return additional_kwargs = message.get("additional_kwargs") if not isinstance(additional_kwargs, dict): additional_kwargs = {} message["additional_kwargs"] = additional_kwargs additional_kwargs.setdefault("turn_duration", run_durations[run_id]) @router.post("/{thread_id}/history", response_model=list[HistoryEntry]) @require_permission("threads", "read", owner_check=True) async def get_thread_history( thread_id: str, body: ThreadHistoryRequest, request: Request, background_tasks: BackgroundTasks, ) -> list[HistoryEntry]: """Get checkpoint history for a thread. Messages are read from the checkpointer's channel values (the authoritative source) and serialized via :func:`~deerflow.runtime.serialization.serialize_channel_values`. Only the latest (first) checkpoint carries the ``messages`` key to avoid duplicating them across every entry. """ checkpointer = get_checkpointer(request) config: dict[str, Any] = {"configurable": {"thread_id": thread_id}} if body.before: config["configurable"]["checkpoint_id"] = body.before entries: list[HistoryEntry] = [] is_latest_checkpoint = True try: async for checkpoint_tuple in checkpointer.alist(config, limit=body.limit): ckpt_config = getattr(checkpoint_tuple, "config", {}) parent_config = getattr(checkpoint_tuple, "parent_config", None) metadata = getattr(checkpoint_tuple, "metadata", {}) or {} checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {} checkpoint_id = ckpt_config.get("configurable", {}).get("checkpoint_id", "") parent_id = None if parent_config: parent_id = parent_config.get("configurable", {}).get("checkpoint_id") channel_values = checkpoint.get("channel_values", {}) # Build values from checkpoint channel_values values: dict[str, Any] = {} if title := channel_values.get("title"): values["title"] = title if thread_data := channel_values.get("thread_data"): values["thread_data"] = thread_data # Attach messages only to the latest checkpoint entry. if is_latest_checkpoint: messages = channel_values.get("messages") if messages: serialized_msgs = serialize_channel_values_for_api({"messages": messages}).get("messages", []) try: # Human messages define turn boundaries. New checkpoints # carry the completed turns' durations in metadata, so the # messages channel stays unchanged. checkpoint_run_durations = _checkpoint_run_durations(metadata) current_turn_run_id = None for msg in serialized_msgs: if msg.get("type") == "human": additional_kwargs = msg.get("additional_kwargs") if isinstance(additional_kwargs, dict): run_id = additional_kwargs.get("run_id") if isinstance(run_id, str) and run_id: current_turn_run_id = run_id continue if msg.get("type") not in {"ai", "tool"} or not current_turn_run_id: continue msg.setdefault("run_id", current_turn_run_id) _set_message_turn_duration(msg, current_turn_run_id, checkpoint_run_durations) # Legacy checkpoints without duration metadata are # correlated once via event-store + run-manager, then # upgraded by a metadata-only checkpoint write. if any(_ai_message_lacks_duration(msg) for msg in serialized_msgs): from app.gateway.deps import get_run_event_store, get_run_manager from app.gateway.routers.thread_runs import compute_run_durations from deerflow.runtime.runs.worker import persist_run_durations run_mgr = get_run_manager(request) event_store = get_run_event_store(request) runs = await run_mgr.list_by_thread(thread_id) events = await event_store.list_messages(thread_id, limit=1000) if runs: run_durations = compute_run_durations(runs) msg_to_run = {} for event in events: content = event.get("content", {}) run_id = event.get("run_id") if isinstance(content, dict) and content.get("type") == "ai" and "id" in content and isinstance(run_id, str) and run_id: msg_to_run[content["id"]] = run_id current_turn_run_id = None for msg in serialized_msgs: if msg.get("type") == "human": additional_kwargs = msg.get("additional_kwargs") if isinstance(additional_kwargs, dict): run_id = additional_kwargs.get("run_id") if isinstance(run_id, str) and run_id: current_turn_run_id = run_id continue if msg.get("type") not in {"ai", "tool"}: continue run_id = msg_to_run.get(msg.get("id")) or current_turn_run_id if run_id: msg["run_id"] = run_id _set_message_turn_duration(msg, run_id, run_durations) # Intentional, best-effort write-on-read migration: # persist legacy metadata after the response so the # history request never waits on an active stream's # same-thread checkpoint lock. background_tasks.add_task( persist_run_durations, checkpointer=checkpointer, thread_id=thread_id, durations=run_durations, ) except Exception: logger.warning("Failed to inject turn_duration for thread %s", thread_id, exc_info=True) values["messages"] = serialized_msgs is_latest_checkpoint = False # Derive next tasks tasks_raw = getattr(checkpoint_tuple, "tasks", []) or [] next_tasks = [t.name for t in tasks_raw if hasattr(t, "name")] # Strip LangGraph internal keys from metadata user_meta = {k: v for k, v in metadata.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents", "run_durations")} # Keep step for ordering context if "step" in metadata: user_meta["step"] = metadata["step"] entries.append( HistoryEntry( checkpoint_id=checkpoint_id, parent_checkpoint_id=parent_id, metadata=user_meta, values=values, created_at=coerce_iso(metadata.get("created_at", "")), next=next_tasks, ) ) except Exception: logger.exception("Failed to get history for thread %s", sanitize_log_param(thread_id)) raise HTTPException(status_code=500, detail="Failed to get thread history") return entries