mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-29 01:15:59 +00:00
* feat(frontend): pin recent chats * fix(threads): address pin-chat review feedback - Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new update_metadata(touch=False) path so unpinning no longer jumps a chat to the top of the updated_at-sorted recent list. - Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching the Gateway's actual response (no values/context). - Namespace the pinned metadata key as deerflow_pinned for consistency with deerflow_sidecar / deerflow_branch. - Cover touch/touch=False behavior in repo + router tests; document the e2e mock's updated_at preservation now mirrors production. * style(frontend): format thread utils test * fix(threads): make pinned ordering server-side * test(frontend): keep infinite-scroll fixture order stable * test(frontend): stabilize lark reconnect e2e * docs: clarify thread pin metadata contract
1479 lines
66 KiB
Python
1479 lines
66 KiB
Python
"""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 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
|
|
from langgraph.types import Overwrite
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.gateway.authz import require_permission
|
|
from app.gateway.checkpoint_lineage import (
|
|
CheckpointLineageError,
|
|
CheckpointParentMissingError,
|
|
find_checkpoint_before_message,
|
|
find_checkpoint_before_message_chronologically,
|
|
is_duration_only_checkpoint,
|
|
)
|
|
from app.gateway.deps import get_checkpointer, get_run_event_store
|
|
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
|
|
from app.gateway.services import (
|
|
build_checkpoint_state_accessor,
|
|
build_checkpoint_state_mutation_accessor,
|
|
build_thread_checkpoint_state_accessor,
|
|
build_thread_checkpoint_state_mutation_accessor,
|
|
reserve_checkpoint_write,
|
|
)
|
|
from app.gateway.utils import sanitize_log_param
|
|
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
|
|
from deerflow.config.paths import Paths, get_paths
|
|
from deerflow.config.summarization_config import ContextSize
|
|
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY
|
|
from deerflow.runtime import serialize_channel_values_for_api
|
|
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError, CheckpointModeReconfigurationError
|
|
from deerflow.runtime.checkpoint_state import graph_reducer_channels, graph_state_schema, graph_writable_channels
|
|
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,
|
|
read_thread_goal,
|
|
write_thread_goal,
|
|
)
|
|
from deerflow.runtime.journal import build_branch_history_seed_events
|
|
from deerflow.runtime.runs.manager import ConflictError
|
|
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"])
|
|
|
|
_CHECKPOINT_MODE_ERRORS = (CheckpointModeMismatchError, CheckpointModeReconfigurationError)
|
|
|
|
|
|
def _checkpoint_mode_http_error(exc: Exception, thread_id: str) -> HTTPException:
|
|
"""Map checkpoint-mode guard failures to precise HTTP statuses.
|
|
|
|
A mismatch means the thread's persisted checkpoints conflict with the
|
|
process's frozen mode (operator-actionable, 409); a reconfiguration means
|
|
the process itself is mid mode-flip (transient, 503). Both must surface
|
|
their message — a generic 500 would force operators to grep logs to
|
|
discover the root cause after a mode flip.
|
|
"""
|
|
if isinstance(exc, CheckpointModeMismatchError):
|
|
return HTTPException(status_code=409, detail=f"Thread {thread_id}: {exc}")
|
|
return HTTPException(status_code=503, detail=str(exc))
|
|
|
|
|
|
# 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"
|
|
# Thread-scoped runtime channels a branch must NOT inherit from its parent:
|
|
# ``sandbox.sandbox_id`` binds path mappings and the release lifecycle to the
|
|
# *parent* thread, so copying it would make the branch read/write the parent's
|
|
# workspace (bypassing the per-branch user-data clone) and release the
|
|
# parent's sandbox after its first run; the branch lazily acquires its own
|
|
# sandbox keyed by its own thread_id instead. ``thread_data`` is recomputed
|
|
# from the branch's thread_id by ThreadDataMiddleware on every run.
|
|
_BRANCH_EXCLUDED_CHANNELS = frozenset({"sandbox", "thread_data"})
|
|
_BRANCH_HISTORY_SCAN_LIMIT = 200
|
|
_BRANCH_HISTORY_RAW_SCAN_LIMIT = _BRANCH_HISTORY_SCAN_LIMIT * 2
|
|
|
|
|
|
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 _is_pin_metadata_patch(metadata: dict[str, Any]) -> bool:
|
|
"""Return True for the narrow pin/unpin PATCH shape."""
|
|
return set(metadata) == {THREAD_PINNED_METADATA_KEY} and isinstance(metadata.get(THREAD_PINNED_METADATA_KEY), bool)
|
|
|
|
|
|
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(snapshot: Any) -> list[Any]:
|
|
values = getattr(snapshot, "values", None) or {}
|
|
messages = values.get("messages") if isinstance(values, dict) else None
|
|
return list(messages) if isinstance(messages, list) else []
|
|
|
|
|
|
def _checkpoint_id(snapshot: Any) -> str | None:
|
|
config = getattr(snapshot, "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 :])
|
|
|
|
|
|
def _branch_target_human_message(messages: list[Any], target_message_ids: set[str]) -> Any | None:
|
|
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 None
|
|
target_start_index = min(index_by_id[message_id] for message_id in target_message_ids)
|
|
return next(
|
|
(message for message in reversed(messages[:target_start_index]) if _message_type(message) == "human" and _is_branch_visible_message(message)),
|
|
None,
|
|
)
|
|
|
|
|
|
async def _find_branch_checkpoint(
|
|
accessor: Any,
|
|
config: dict[str, Any],
|
|
target_message_ids: set[str],
|
|
) -> Any:
|
|
try:
|
|
for snapshot in await accessor.ahistory(config, limit=_BRANCH_HISTORY_RAW_SCAN_LIMIT):
|
|
if is_duration_only_checkpoint(snapshot):
|
|
continue
|
|
if _matches_branch_target(_checkpoint_messages(snapshot), target_message_ids):
|
|
return snapshot
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, config.get("configurable", {}).get("thread_id", "")) from exc
|
|
except Exception:
|
|
thread_id = config.get("configurable", {}).get("thread_id", "")
|
|
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(
|
|
accessor: Any,
|
|
config: dict[str, Any],
|
|
target_message_ids: set[str],
|
|
) -> bool:
|
|
"""Return whether the target turn is the final visible turn."""
|
|
try:
|
|
for snapshot in await accessor.ahistory(config, limit=_BRANCH_HISTORY_RAW_SCAN_LIMIT):
|
|
if is_duration_only_checkpoint(snapshot):
|
|
continue
|
|
messages = _checkpoint_messages(snapshot)
|
|
if not messages:
|
|
continue
|
|
return _matches_branch_target(messages, target_message_ids)
|
|
except Exception:
|
|
thread_id = config.get("configurable", {}).get("thread_id", "")
|
|
logger.warning(
|
|
"Failed to resolve latest turn for thread %s; treating branch as historical",
|
|
sanitize_log_param(thread_id),
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
|
|
async def _find_branch_replay_base(
|
|
accessor: Any,
|
|
config: dict[str, Any],
|
|
snapshot: Any,
|
|
target_human_id: str,
|
|
) -> Any | None:
|
|
"""Resolve a replay base while preserving unlinked legacy histories."""
|
|
|
|
try:
|
|
return await find_checkpoint_before_message(
|
|
accessor,
|
|
snapshot,
|
|
target_human_id,
|
|
max_depth=_BRANCH_HISTORY_RAW_SCAN_LIMIT,
|
|
)
|
|
except CheckpointParentMissingError:
|
|
thread_id = config.get("configurable", {}).get("thread_id", "")
|
|
logger.debug(
|
|
"Could not resolve parent lineage for branch thread %s; falling back to history scan",
|
|
sanitize_log_param(thread_id),
|
|
exc_info=True,
|
|
)
|
|
except CheckpointLineageError as exc:
|
|
thread_id = config.get("configurable", {}).get("thread_id", "")
|
|
logger.warning(
|
|
"Rejected unsafe checkpoint lineage for branch thread %s",
|
|
sanitize_log_param(thread_id),
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(status_code=409, detail="This turn can no longer be branched from.") from exc
|
|
|
|
try:
|
|
history = await accessor.ahistory(config, limit=_BRANCH_HISTORY_RAW_SCAN_LIMIT)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
thread_id = config.get("configurable", {}).get("thread_id", "")
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
except Exception as exc:
|
|
thread_id = config.get("configurable", {}).get("thread_id", "")
|
|
logger.exception("Failed to scan replay checkpoint history for thread %s", sanitize_log_param(thread_id))
|
|
raise HTTPException(status_code=500, detail="Failed to inspect checkpoint history") from exc
|
|
|
|
replay_base, target_found = find_checkpoint_before_message_chronologically(history, target_human_id)
|
|
if not target_found:
|
|
logger.warning(
|
|
"Could not locate branch user message %s in chronological history for thread %s",
|
|
sanitize_log_param(target_human_id),
|
|
sanitize_log_param(config.get("configurable", {}).get("thread_id", "")),
|
|
)
|
|
return replay_base
|
|
|
|
|
|
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")
|
|
model_name: str | None = Field(default=None, max_length=128, description="Optional model to summarize with; resolved request override -> custom-agent model -> default, mirroring run model selection")
|
|
|
|
|
|
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
|
|
# "seeded" | "skipped_empty" | "failed" — whether the parent history was
|
|
# copied into the branch's run-event feed (see branch_thread).
|
|
history_seed_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}")
|
|
|
|
|
|
async def _fetch_raw_pending_writes(checkpointer: Any, config: dict[str, Any]) -> list[Any]:
|
|
"""Fetch pending writes attached to a specific checkpoint.
|
|
|
|
Snapshot ``tasks`` only reflect writes that were pending while a task was
|
|
still scheduled; writes attached to the latest checkpoint afterwards
|
|
(rollback reattachment, worker error fallback) never surface there, so the
|
|
status derivation needs one raw tuple fetch on the resolved checkpoint.
|
|
"""
|
|
raw_tuple = await checkpointer.aget_tuple(config)
|
|
if raw_tuple is None:
|
|
return []
|
|
return list(getattr(raw_tuple, "pending_writes", ()) or ())
|
|
|
|
|
|
def _derive_thread_status(snapshot: Any, pending_writes: list[Any], *, fallback_status: str = "idle") -> str:
|
|
"""Derive thread status from the materialized snapshot plus the raw
|
|
pending writes attached to the resolved checkpoint."""
|
|
if snapshot is None:
|
|
return "idle"
|
|
|
|
for write in pending_writes:
|
|
if isinstance(write, (list, tuple)) and len(write) >= 2 and write[1] == "__error__":
|
|
return "error"
|
|
|
|
tasks = getattr(snapshot, "tasks", None) or ()
|
|
for task in tasks:
|
|
if getattr(task, "error", None) is not None:
|
|
return "error"
|
|
|
|
if not getattr(snapshot, "tasks_known", True):
|
|
return fallback_status
|
|
|
|
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))
|
|
|
|
# Tear down any live browser session (best-effort). Sessions are keyed only
|
|
# by thread_id, so leaving one alive after the owner deletes the thread lets
|
|
# a later caller who guesses the id reuse the retained page/cookies.
|
|
try:
|
|
from deerflow.community.browser_automation import get_browser_session_manager
|
|
|
|
await get_browser_session_manager().close_session(thread_id)
|
|
except ImportError:
|
|
pass # Playwright is an optional dependency.
|
|
except Exception:
|
|
logger.debug("Could not close browser session 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
|
|
|
|
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.")
|
|
source_accessor, source_config = build_checkpoint_state_accessor(
|
|
request,
|
|
thread_id=thread_id,
|
|
assistant_id=source_record.get("assistant_id"),
|
|
)
|
|
|
|
target_message_ids = {body.message_id, *body.message_ids}
|
|
snapshot = await _find_branch_checkpoint(source_accessor, source_config, target_message_ids)
|
|
parent_checkpoint_id = _checkpoint_id(snapshot)
|
|
if not parent_checkpoint_id:
|
|
raise HTTPException(status_code=409, detail="This turn can no longer be branched from.")
|
|
target_human = _branch_target_human_message(_checkpoint_messages(snapshot), target_message_ids)
|
|
target_human_id = _message_id(target_human)
|
|
if not target_human_id:
|
|
raise HTTPException(status_code=409, detail="This turn can no longer be branched from.")
|
|
replay_base_tuple = await _find_branch_replay_base(
|
|
source_accessor,
|
|
source_config,
|
|
snapshot,
|
|
target_human_id,
|
|
)
|
|
|
|
# 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(source_accessor, source_config, 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 {}
|
|
|
|
# Copy materialized values with replace semantics: reducer channels must
|
|
# not re-merge an already-aggregated value, so every copied reducer value
|
|
# is wrapped in Overwrite (not just messages).
|
|
branch_accessor, new_config = build_checkpoint_state_mutation_accessor(
|
|
request,
|
|
thread_id=new_thread_id,
|
|
as_node="branch",
|
|
# The branch write carries the full materialized snapshot; use the
|
|
# source assistant's effective schema so extension middleware channels
|
|
# survive instead of being silently discarded as unknown channels.
|
|
state_schema=graph_state_schema(getattr(source_accessor, "graph", None)),
|
|
)
|
|
branch_reducer_fields = graph_reducer_channels(getattr(branch_accessor, "graph", None))
|
|
if branch_reducer_fields is None:
|
|
branch_reducer_fields = THREAD_STATE_REDUCER_FIELDS
|
|
|
|
def branch_values(source_snapshot: Any) -> dict[str, Any]:
|
|
values: dict[str, Any] = {}
|
|
for key, value in dict(source_snapshot.values).items():
|
|
if key in _BRANCH_EXCLUDED_CHANNELS:
|
|
continue
|
|
if key in branch_reducer_fields:
|
|
values[key] = Overwrite(list(value) if key == "messages" and isinstance(value, list) else value)
|
|
else:
|
|
values[key] = value
|
|
return values
|
|
|
|
# Stamp both synthetic checkpoints with the branch-creation time because
|
|
# serializers fall back to metadata when snapshot.created_at is absent.
|
|
checkpoint_metadata_updates = {
|
|
**branch_metadata,
|
|
"source": "branch",
|
|
"updated_at": now,
|
|
"created_at": now,
|
|
}
|
|
new_config.setdefault("metadata", {}).update(checkpoint_metadata_updates)
|
|
try:
|
|
head_config = new_config
|
|
if replay_base_tuple is not None:
|
|
head_config = await branch_accessor.aupdate(
|
|
new_config,
|
|
branch_values(replay_base_tuple),
|
|
as_node="branch",
|
|
)
|
|
head_config.setdefault("metadata", {}).update(checkpoint_metadata_updates)
|
|
await branch_accessor.aupdate(
|
|
head_config,
|
|
branch_values(snapshot),
|
|
as_node="branch",
|
|
)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, new_thread_id) from exc
|
|
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
|
|
|
|
# The thread feed (GET /messages, /messages/page) reads the run-event
|
|
# store, not checkpoints, and a fresh branch has no run_events — so the
|
|
# inherited history would vanish from the UI as soon as the branch's
|
|
# first run refreshes the feed (#4380 problem 2). Seed the branch's
|
|
# run_events from the same checkpoint snapshot the branch was created
|
|
# from. Best-effort: on failure the branch stays usable, with history
|
|
# visible only through the checkpoint overlay until it is re-branched.
|
|
try:
|
|
seed_events = build_branch_history_seed_events(
|
|
_checkpoint_messages(snapshot),
|
|
thread_id=new_thread_id,
|
|
run_id_prefix=f"branch-seed-{new_thread_id}",
|
|
parent_thread_id=thread_id,
|
|
)
|
|
if seed_events:
|
|
await get_run_event_store(request).put_batch(seed_events)
|
|
history_seed_mode = "seeded"
|
|
else:
|
|
history_seed_mode = "skipped_empty"
|
|
except Exception:
|
|
logger.exception("Failed to seed branch history run-events for thread %s", sanitize_log_param(new_thread_id))
|
|
history_seed_mode = "failed"
|
|
|
|
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,
|
|
history_seed_mode=history_seed_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``.
|
|
# Pin/unpin is not conversation activity, so it must not bump ``updated_at``.
|
|
# Other metadata PATCH callers keep the public endpoint's existing recency
|
|
# contract unless they get their own explicit no-touch API surface.
|
|
touch = not _is_pin_metadata_patch(body.metadata)
|
|
try:
|
|
await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
|
|
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 and the store's timestamp decision.
|
|
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 from metadata plus the graph's materialized state."""
|
|
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)
|
|
try:
|
|
accessor, config = build_checkpoint_state_accessor(
|
|
request,
|
|
thread_id=thread_id,
|
|
assistant_id=record.get("assistant_id") if record is not None else None,
|
|
)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
|
|
try:
|
|
snapshot = await accessor.aget(config)
|
|
checkpoint_id = (snapshot.config or {}).get("configurable", {}).get("checkpoint_id")
|
|
pending_writes = await _fetch_raw_pending_writes(checkpointer, snapshot.config) if checkpoint_id else []
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
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 not checkpoint_id:
|
|
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
|
|
|
metadata = snapshot.metadata or {}
|
|
if record is None:
|
|
record = {
|
|
"thread_id": thread_id,
|
|
"status": "idle",
|
|
"created_at": coerce_iso(snapshot.created_at or metadata.get("created_at", "")),
|
|
"updated_at": coerce_iso(metadata.get("updated_at", snapshot.created_at or metadata.get("created_at", ""))),
|
|
"metadata": {key: value for key, value in metadata.items() if key not in ("created_at", "updated_at", "step", "source", "writes", "parents")},
|
|
}
|
|
stored_status = record.get("status", "idle")
|
|
status = _derive_thread_status(snapshot, pending_writes, fallback_status=stored_status) if checkpoint_id else stored_status
|
|
|
|
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(snapshot.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)
|
|
try:
|
|
goal = build_goal_state(body.objective, max_continuations=body.max_continuations)
|
|
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
|
|
await _ensure_thread_for_goal(thread_id, request)
|
|
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 ConflictError:
|
|
raise HTTPException(status_code=409, detail="Thread has a run in flight. Set the goal after the run finishes.") from None
|
|
except HTTPException:
|
|
raise
|
|
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 reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
|
|
await write_thread_goal(checkpointer, thread_id, None, as_node="goal")
|
|
except ConflictError:
|
|
raise HTTPException(status_code=409, detail="Thread has a run in flight. Clear the goal after the run finishes.") from None
|
|
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."""
|
|
# Compaction writes only base-schema channels (messages + summary_text);
|
|
# every other channel — including middleware-contributed ones — is carried
|
|
# forward by checkpoint fork inheritance, so the base-schema mutation
|
|
# graph is sufficient (and avoids building the full lead graph per call).
|
|
try:
|
|
accessor, _ = build_checkpoint_state_mutation_accessor(
|
|
request,
|
|
thread_id=thread_id,
|
|
as_node="manual_compaction",
|
|
)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
keep = body.keep.to_tuple() if body.keep is not None else None
|
|
try:
|
|
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
|
|
result = await compact_thread_context(
|
|
accessor,
|
|
thread_id,
|
|
keep=keep,
|
|
force=body.force,
|
|
user_id=get_effective_user_id(),
|
|
agent_name=body.agent_name,
|
|
model_name=body.model_name,
|
|
)
|
|
except ConflictError:
|
|
raise HTTPException(status_code=409, detail="Thread has a run in flight. Compact after the run finishes.") from None
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
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 materialized graph state for a thread."""
|
|
# Resolve through the thread's assistant so custom middleware channels
|
|
# appear in the response instead of being dropped by the default schema.
|
|
try:
|
|
accessor, config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
try:
|
|
snapshot = await accessor.aget(config)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
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")
|
|
|
|
snapshot_config = snapshot.config or {}
|
|
checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id")
|
|
if not checkpoint_id:
|
|
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
|
|
|
parent_config = snapshot.parent_config or {}
|
|
parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id")
|
|
metadata = snapshot.metadata or {}
|
|
created_at = snapshot.created_at or metadata.get("created_at", "")
|
|
tasks_raw = snapshot.tasks or ()
|
|
tasks = [{"id": getattr(task, "id", ""), "name": getattr(task, "name", "")} for task in tasks_raw]
|
|
|
|
return ThreadStateResponse(
|
|
values=serialize_channel_values_for_api(snapshot.values),
|
|
next=list(snapshot.next or ()),
|
|
metadata=metadata,
|
|
checkpoint={"id": checkpoint_id, "ts": coerce_iso(created_at)},
|
|
checkpoint_id=checkpoint_id,
|
|
parent_checkpoint_id=parent_checkpoint_id,
|
|
created_at=coerce_iso(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:
|
|
"""Replace selected thread-state fields through the materialized graph."""
|
|
from app.gateway.deps import get_thread_store
|
|
|
|
thread_store = get_thread_store(request)
|
|
if body.checkpoint_id is not None:
|
|
if not body.checkpoint_id:
|
|
raise HTTPException(status_code=404, detail="Checkpoint not found")
|
|
selected_config = {
|
|
"configurable": {
|
|
"thread_id": thread_id,
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": body.checkpoint_id,
|
|
}
|
|
}
|
|
try:
|
|
checkpoint_tuple = await get_checkpointer(request).aget_tuple(selected_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"Checkpoint {body.checkpoint_id} not found")
|
|
|
|
mutation_node = body.as_node or "manual_state_update"
|
|
# Resolve through the shared boundary (thread metadata -> assistant_id ->
|
|
# effective schema) so extension middleware channels stay writable.
|
|
accessor, read_config = await build_thread_checkpoint_state_mutation_accessor(
|
|
request,
|
|
thread_id=thread_id,
|
|
as_node=mutation_node,
|
|
checkpoint_id=body.checkpoint_id,
|
|
)
|
|
values = dict(body.values or {})
|
|
writable_channels = graph_writable_channels(getattr(accessor, "graph", None))
|
|
if writable_channels is not None:
|
|
unknown_fields = sorted(set(values) - writable_channels)
|
|
if unknown_fields:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Unknown thread-state field(s): {', '.join(unknown_fields)}",
|
|
)
|
|
reducer_fields = graph_reducer_channels(getattr(accessor, "graph", None))
|
|
if reducer_fields is None:
|
|
reducer_fields = THREAD_STATE_REDUCER_FIELDS
|
|
updates = {key: Overwrite(value) if key in reducer_fields else value for key, value in values.items()}
|
|
try:
|
|
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
|
|
updated_config = await accessor.aupdate(
|
|
read_config,
|
|
updates,
|
|
as_node=mutation_node,
|
|
)
|
|
snapshot = await accessor.aget(updated_config)
|
|
except ConflictError:
|
|
raise HTTPException(status_code=409, detail="Thread has a run in flight. Update state after the run finishes.") from None
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
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")
|
|
|
|
if thread_store and body.values and "title" in body.values:
|
|
new_title = body.values["title"]
|
|
if new_title:
|
|
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))
|
|
|
|
snapshot_config = snapshot.config or {}
|
|
checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id")
|
|
parent_config = snapshot.parent_config or {}
|
|
parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id")
|
|
metadata = snapshot.metadata or {}
|
|
created_at = snapshot.created_at or metadata.get("created_at", "")
|
|
tasks_raw = snapshot.tasks or ()
|
|
tasks = [{"id": getattr(task, "id", ""), "name": getattr(task, "name", "")} for task in tasks_raw]
|
|
|
|
return ThreadStateResponse(
|
|
values=serialize_channel_values_for_api(snapshot.values),
|
|
next=list(snapshot.next or ()),
|
|
metadata=metadata,
|
|
checkpoint={"id": checkpoint_id, "ts": coerce_iso(created_at)},
|
|
checkpoint_id=checkpoint_id,
|
|
parent_checkpoint_id=parent_checkpoint_id,
|
|
created_at=coerce_iso(created_at),
|
|
tasks=tasks,
|
|
)
|
|
|
|
|
|
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 materialized graph state history for a thread.
|
|
|
|
Only the latest (first) checkpoint carries the ``messages`` key to
|
|
avoid duplicating the complete conversation across every entry.
|
|
"""
|
|
checkpointer = get_checkpointer(request)
|
|
try:
|
|
accessor, config = await build_thread_checkpoint_state_accessor(
|
|
request,
|
|
thread_id=thread_id,
|
|
checkpoint_id=body.before,
|
|
)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
|
|
entries: list[HistoryEntry] = []
|
|
is_latest_checkpoint = True
|
|
try:
|
|
snapshots = await accessor.ahistory(config, limit=body.limit)
|
|
for snapshot in snapshots:
|
|
snapshot_config = snapshot.config or {}
|
|
parent_config = snapshot.parent_config or {}
|
|
metadata = snapshot.metadata or {}
|
|
materialized_values = snapshot.values if isinstance(snapshot.values, dict) else {}
|
|
|
|
checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id", "")
|
|
parent_id = parent_config.get("configurable", {}).get("checkpoint_id")
|
|
|
|
values: dict[str, Any] = {}
|
|
if title := materialized_values.get("title"):
|
|
values["title"] = title
|
|
if thread_data := materialized_values.get("thread_data"):
|
|
values["thread_data"] = thread_data
|
|
|
|
if is_latest_checkpoint:
|
|
messages = materialized_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
|
|
|
|
next_tasks = list(snapshot.next or ())
|
|
|
|
# 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(snapshot.created_at or metadata.get("created_at", "")),
|
|
next=next_tasks,
|
|
)
|
|
)
|
|
except _CHECKPOINT_MODE_ERRORS as exc:
|
|
raise _checkpoint_mode_http_error(exc, thread_id) from exc
|
|
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
|