rayhpeng fd74553f91 Merge branch 'main' into rayhpeng/hexagonal-feedback-slice
Rebase the feedback migration onto main's new chain tip again: main added
0009_webhook_dedupe (also chained after 0008_thread_operation_kind), so
0009_feedback_tags becomes 0010_feedback_tags with
down_revision=0009_webhook_dedupe, keeping the chain linear.

Conflicts resolved:
- Five head-pin tests take main's version with the pin bumped to
  0010_feedback_tags.
- test_thread_messages_page.py keeps this branch's feedback_service
  wiring over main's feedback_repo wiring, but stubs both service
  methods so the thread-grouped path main added coverage for stays
  stubbed (latest_per_run_in_thread alongside latest_for_runs).
- test_thread_messages_feedback.py keeps both sides' imports; each is
  used (Feedback for the fixture, EditReplayVisibility for the run
  manager stub).

Verified: 79 tests across the conflicted files plus the migration and
bootstrap suites, and 54 feedback tests, all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:13:25 +08:00

1325 lines
54 KiB
Python

"""Runs endpoints — create, stream, wait, cancel.
Implements the LangGraph Platform runs API on top of
:class:`deerflow.agents.runs.RunManager` and
:class:`deerflow.agents.stream_bridge.StreamBridge`.
SSE format is aligned with the LangGraph Platform protocol so that
the ``useStream`` React hook from ``@langchain/langgraph-sdk/react``
works without modification.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse
from langchain_core.messages import BaseMessage
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.checkpoint_lineage import (
CheckpointLineageError,
CheckpointParentMissingError,
checkpoint_configurable,
checkpoint_messages,
find_checkpoint_before_message,
find_checkpoint_before_message_chronologically,
is_duration_only_checkpoint,
)
from app.gateway.deps import get_current_user, get_feedback_service, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
from app.gateway.pagination import trim_run_message_page
from app.gateway.run_models import RunCreateRequest
from app.gateway.services import build_checkpoint_state_accessor, build_thread_checkpoint_state_accessor, sse_consumer, start_run, wait_for_run_completion
from app.gateway.utils import sanitize_log_param
from deerflow.runtime import CancelOutcome, RunRecord, RunStatus, serialize_channel_values_for_api
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text, message_to_text
from deerflow.workspace_changes import get_workspace_changes_response
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["runs"])
REGENERATE_HISTORY_SCAN_LIMIT = 200
# Doubled to keep ~200 effective checkpoints when duration-only checkpoints
# (one per successful run in steady state) consume roughly half of history.
REGENERATE_HISTORY_RAW_SCAN_LIMIT = REGENERATE_HISTORY_SCAN_LIMIT * 2
THREAD_MESSAGE_PAGE_SCAN_BATCH = 201
_MISSING_REGENERATE_BASE_DETAIL = "Could not find an addressable checkpoint before the target user message"
_UNSAFE_REGENERATE_LINEAGE_DETAIL = "Could not safely resolve the checkpoint before the target user message"
THREAD_MESSAGE_LEGACY_SCAN_BATCH = 201
def _is_duration_only_checkpoint(checkpoint_tuple: Any) -> bool:
return is_duration_only_checkpoint(checkpoint_tuple)
def compute_run_durations(runs) -> dict[str, int]:
"""Map run_id -> duration in seconds from run timestamps."""
from datetime import datetime
durations: dict[str, int] = {}
for r in runs:
if r.created_at and r.updated_at:
try:
created = datetime.fromisoformat(r.created_at.replace("Z", "+00:00"))
updated = datetime.fromisoformat(r.updated_at.replace("Z", "+00:00"))
# Note: updated_at - created_at represents the row's total lifetime,
# which can slightly overshoot the actual AI turn end if the row is mutated later.
durations[r.run_id] = int((updated - created).total_seconds())
except Exception:
logger.warning("Failed to parse timestamps for run %s", r.run_id, exc_info=True)
return durations
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class RegeneratePrepareRequest(BaseModel):
message_id: str = Field(..., min_length=1, description="Assistant message id to regenerate")
class RegeneratePrepareResponse(BaseModel):
input: dict[str, Any]
checkpoint: dict[str, Any]
metadata: dict[str, Any]
target_run_id: str
class EditRegeneratePrepareRequest(BaseModel):
human_message_id: str = Field(..., min_length=1, description="Source human message id to edit and rerun")
replacement_text: str = Field(..., min_length=1, description="Replacement user-visible text")
class EditRegeneratePrepareResponse(RegeneratePrepareResponse):
replacement_human_message_id: str
source_message_ids: list[str]
class ThreadMessagesPageResponse(BaseModel):
data: list[dict[str, Any]]
has_more: bool
next_before_seq: int | None = None
class RunResponse(BaseModel):
run_id: str
thread_id: str
assistant_id: str | None = None
status: str
metadata: dict[str, Any] = Field(default_factory=dict)
kwargs: dict[str, Any] = Field(default_factory=dict)
multitask_strategy: str = "reject"
created_at: str = ""
updated_at: str = ""
total_input_tokens: int = 0
total_output_tokens: int = 0
total_tokens: int = 0
llm_call_count: int = 0
lead_agent_tokens: int = 0
subagent_tokens: int = 0
middleware_tokens: int = 0
message_count: int = 0
stop_reason: str | None = None
class ThreadTokenUsageModelBreakdown(BaseModel):
tokens: int = 0
runs: int = Field(
default=0,
description="Number of runs in which this model appeared; counts are non-exclusive for runs that used multiple models.",
)
class ThreadTokenUsageCallerBreakdown(BaseModel):
lead_agent: int = 0
subagent: int = 0
middleware: int = 0
class ThreadTokenUsageResponse(BaseModel):
thread_id: str
total_tokens: int = 0
total_input_tokens: int = 0
total_output_tokens: int = 0
total_runs: int = 0
by_model: dict[str, ThreadTokenUsageModelBreakdown] = Field(default_factory=dict)
by_caller: ThreadTokenUsageCallerBreakdown = Field(default_factory=ThreadTokenUsageCallerBreakdown)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _cancel_conflict_detail(run_id: str, record: RunRecord) -> str:
if record.status in (RunStatus.pending, RunStatus.running):
return f"Run {run_id} is not active on this worker and cannot be cancelled"
return f"Run {run_id} is not cancellable (status: {record.status.value})"
def _compute_retry_after(lease_expires_at: str | None, grace_seconds: int) -> int | None:
"""Return seconds until the lease expires + grace, for ``Retry-After``.
Returns ``None`` when the lease is NULL or unparseable so the caller
can decide whether to send a generic 409 without the header.
The ``max(1, ...)`` floor means a lease just about to expire yields
``Retry-After: 1``. This is a lower bound, not a recommended poll
interval — clients that honour this header should apply minimum
backoff / jitter rather than retrying every second.
"""
if lease_expires_at is None:
return None
try:
dt = datetime.fromisoformat(lease_expires_at)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
except (ValueError, TypeError):
return None
remaining = (dt - datetime.now(UTC)).total_seconds() + grace_seconds
return max(1, int(remaining))
async def _raise_lease_valid_elsewhere(
run_id: str,
run_mgr, # RunManager (avoid import for testability)
record: RunRecord,
) -> None:
"""Re-fetch the lease and raise HTTP 409 + Retry-After.
``record.lease_expires_at`` may be stale (fetched at request start while
the owner renewed between our read and the conditional UPDATE). Re-read
from the store to get the fresh value so ``Retry-After`` is accurate.
"""
fresh = await run_mgr.get(run_id)
if fresh is not None:
record = fresh
retry_after = _compute_retry_after(record.lease_expires_at, run_mgr.grace_seconds)
headers: dict[str, str] = {}
if retry_after is not None:
headers["Retry-After"] = str(retry_after)
raise HTTPException(
status_code=409,
detail=f"Run {run_id} is active on another worker; retry after lease expiry.",
headers=headers,
)
def _record_to_response(record: RunRecord) -> RunResponse:
return RunResponse(
run_id=record.run_id,
thread_id=record.thread_id,
assistant_id=record.assistant_id,
status=record.status.value,
metadata=record.metadata,
kwargs=record.kwargs,
multitask_strategy=record.multitask_strategy,
created_at=record.created_at,
updated_at=record.updated_at,
total_input_tokens=record.total_input_tokens,
total_output_tokens=record.total_output_tokens,
total_tokens=record.total_tokens,
llm_call_count=record.llm_call_count,
lead_agent_tokens=record.lead_agent_tokens,
subagent_tokens=record.subagent_tokens,
middleware_tokens=record.middleware_tokens,
message_count=record.message_count,
stop_reason=record.stop_reason,
)
def _message_id(message: Any) -> str | None:
value = getattr(message, "id", None)
if value is None and isinstance(message, dict):
value = message.get("id")
return str(value) if value else None
def _message_type(message: Any) -> str | None:
value = getattr(message, "type", None)
if value is None and isinstance(message, dict):
value = message.get("type") or message.get("role")
if value == "assistant":
return "ai"
return str(value) if value else None
def _message_name(message: Any) -> str | None:
value = getattr(message, "name", None)
if value is None and isinstance(message, dict):
value = message.get("name")
return str(value) if value else None
def _message_content(message: Any) -> Any:
if isinstance(message, dict):
return message.get("content")
return getattr(message, "content", None)
def _message_text(message: Any) -> str:
return message_to_text(message)
def _message_additional_kwargs(message: Any) -> dict[str, Any]:
value = getattr(message, "additional_kwargs", None)
if value is None and isinstance(message, dict):
value = message.get("additional_kwargs")
return dict(value or {}) if isinstance(value, dict) else {}
def _message_tool_calls(message: Any) -> list[Any]:
value = getattr(message, "tool_calls", None)
if value is None and isinstance(message, dict):
value = message.get("tool_calls")
if value is None:
value = _message_additional_kwargs(message).get("tool_calls")
return list(value) if isinstance(value, list) else []
def _is_hidden_or_control_message(message: Any) -> bool:
message_type = _message_type(message)
additional_kwargs = _message_additional_kwargs(message)
return message_type == "remove" or _message_name(message) == "summary" or additional_kwargs.get("hide_from_ui") is True
def _is_visible_human_message(message: Any) -> bool:
return _message_type(message) == "human" and not _is_hidden_or_control_message(message)
def _is_visible_ai_message(message: Any) -> bool:
return _message_type(message) == "ai" and not _is_hidden_or_control_message(message)
def _is_thread_history_hidden_message_row(row: dict[str, Any]) -> bool:
caller = str((row.get("metadata") or {}).get("caller", ""))
return caller.startswith("middleware:") or (caller.startswith("subagent:") and _message_type(row.get("content")) == "ai")
def _checkpoint_messages(snapshot: Any) -> list[Any]:
return checkpoint_messages(snapshot)
def _checkpoint_values(snapshot: Any) -> dict[str, Any]:
values = getattr(snapshot, "values", None)
return dict(values) if isinstance(values, dict) else {}
def _checkpoint_configurable(checkpoint_tuple: Any) -> dict[str, Any]:
return checkpoint_configurable(checkpoint_tuple)
def _checkpoint_response(checkpoint_tuple: Any) -> dict[str, Any]:
configurable = _checkpoint_configurable(checkpoint_tuple)
checkpoint_id = configurable.get("checkpoint_id")
if not checkpoint_id:
raise HTTPException(status_code=409, detail="Checkpoint is missing checkpoint_id")
return {
"checkpoint_ns": str(configurable.get("checkpoint_ns") or ""),
"checkpoint_id": str(checkpoint_id),
"checkpoint_map": configurable.get("checkpoint_map"),
}
def _clean_human_message_for_regenerate(message: Any) -> dict[str, Any]:
additional_kwargs = _message_additional_kwargs(message)
content = get_original_user_content_text(_message_content(message), additional_kwargs)
additional_kwargs.pop(ORIGINAL_USER_CONTENT_KEY, None)
additional_kwargs.pop("hide_from_ui", None)
clean_message: dict[str, Any] = {
"type": "human",
"content": [{"type": "text", "text": content}],
"additional_kwargs": additional_kwargs,
}
message_id = _message_id(message)
if message_id:
clean_message["id"] = message_id
name = _message_name(message)
if name:
clean_message["name"] = name
return clean_message
def _clean_human_message_for_edit(message: Any, *, replacement_id: str, replacement_text: str) -> dict[str, Any]:
source_kwargs = _message_additional_kwargs(message)
additional_kwargs: dict[str, Any] = {}
for key in ("files", "referenced_message_contexts"):
if key in source_kwargs:
additional_kwargs[key] = deepcopy(source_kwargs[key])
clean_message: dict[str, Any] = {
"type": "human",
"id": replacement_id,
"content": [{"type": "text", "text": replacement_text}],
"additional_kwargs": additional_kwargs,
}
name = _message_name(message)
if name:
clean_message["name"] = name
return clean_message
def _is_terminal_assistant_text_message(message: Any) -> bool:
return _is_visible_ai_message(message) and bool(_message_text(message).strip()) and not _message_tool_calls(message)
def _has_active_goal(snapshot: Any) -> bool:
goal = _checkpoint_values(snapshot).get("goal")
return isinstance(goal, dict) and goal.get("status") == "active"
def _latest_editable_turn(messages: list[Any], human_message_id: str) -> tuple[int, Any, int, Any, list[str]]:
latest_human_index = next((index for index in range(len(messages) - 1, -1, -1) if _is_visible_human_message(messages[index])), None)
if latest_human_index is None or _message_id(messages[latest_human_index]) != human_message_id:
raise HTTPException(status_code=409, detail="Only the latest completed user turn can be edited")
source_human = messages[latest_human_index]
last_ai_index: int | None = None
for index, message in enumerate(messages[latest_human_index + 1 :], start=latest_human_index + 1):
if _is_visible_human_message(message):
break
if _is_visible_ai_message(message):
last_ai_index = index
if last_ai_index is None or not _is_terminal_assistant_text_message(messages[last_ai_index]):
raise HTTPException(status_code=409, detail="Only completed assistant text turns can be edited")
source_message_ids = [message_id for message in messages[latest_human_index : last_ai_index + 1] if (message_id := _message_id(message))]
return latest_human_index, source_human, last_ai_index, messages[last_ai_index], source_message_ids
def _event_message_id(row: dict[str, Any]) -> str | None:
content = row.get("content")
if isinstance(content, BaseMessage):
return _message_id(content)
if isinstance(content, dict):
return _message_id(content)
return None
def _run_last_ai_matches_message(record: RunRecord, message: Any) -> bool:
last_ai_message = (record.last_ai_message or "").strip()
if not last_ai_message:
return False
target_text = _message_text(message).strip()
if not target_text:
return False
return last_ai_message == target_text[: len(last_ai_message)]
async def _find_target_run_id(
thread_id: str,
message_id: str,
target_message: Any,
source_human: Any,
request: Request,
) -> str:
event_store = get_run_event_store(request)
rows = await event_store.list_messages(thread_id, limit=REGENERATE_HISTORY_SCAN_LIMIT)
for row in reversed(rows):
if row.get("event_type") not in {"ai_message", "llm.ai.response"}:
continue
if _event_message_id(row) == message_id:
run_id = row.get("run_id")
if isinstance(run_id, str) and run_id:
return run_id
source_run_id = _message_additional_kwargs(source_human).get("run_id")
if isinstance(source_run_id, str) and source_run_id:
return source_run_id
run_mgr = get_run_manager(request)
user_id = await get_current_user(request)
records = await run_mgr.list_by_thread(thread_id, user_id=user_id, limit=10)
fallback_record = next(
(record for record in records if record.status == RunStatus.success and _run_last_ai_matches_message(record, target_message)),
None,
)
if fallback_record is not None:
return fallback_record.run_id
if len(rows) >= REGENERATE_HISTORY_SCAN_LIMIT:
logger.warning(
"Could not find source run for regenerate message %s in recent run events for thread %s (limit=%s)",
message_id,
thread_id,
REGENERATE_HISTORY_SCAN_LIMIT,
)
raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
async def _find_base_checkpoint_before_human(
thread_id: str,
human_message_id: str,
request: Request,
*,
head_checkpoint: Any | None = None,
) -> Any:
accessor, base_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
if head_checkpoint is not None:
try:
return await find_checkpoint_before_message(
accessor,
head_checkpoint,
human_message_id,
max_depth=REGENERATE_HISTORY_RAW_SCAN_LIMIT,
)
except CheckpointParentMissingError:
# Old checkpoints and imported histories may not have parent links.
# Preserve the bounded chronological fallback for those records.
logger.debug(
"Could not resolve parent lineage for regenerate thread %s; falling back to history scan",
sanitize_log_param(thread_id),
exc_info=True,
)
except CheckpointLineageError as exc:
logger.warning(
"Rejected unsafe checkpoint lineage for regenerate thread %s",
sanitize_log_param(thread_id),
exc_info=True,
)
raise HTTPException(status_code=409, detail=_UNSAFE_REGENERATE_LINEAGE_DETAIL) from exc
try:
raw_checkpoints = await accessor.ahistory(base_config, limit=REGENERATE_HISTORY_RAW_SCAN_LIMIT)
checkpoints = [item for item in raw_checkpoints if not _is_duration_only_checkpoint(item)]
except Exception as exc:
logger.exception("Failed to list checkpoints for regenerate thread %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to inspect checkpoint history") from exc
previous_checkpoint, target_found = find_checkpoint_before_message_chronologically(raw_checkpoints, human_message_id)
if target_found:
if previous_checkpoint is None:
raise HTTPException(
status_code=409,
detail=_MISSING_REGENERATE_BASE_DETAIL,
)
return previous_checkpoint
if len(checkpoints) >= REGENERATE_HISTORY_SCAN_LIMIT:
logger.warning(
"Could not locate target user message %s in recent checkpoint history for thread %s (limit=%s)",
human_message_id,
thread_id,
REGENERATE_HISTORY_SCAN_LIMIT,
)
raise HTTPException(
status_code=409,
detail=(f"Could not locate target user message in recent checkpoint history (limit={REGENERATE_HISTORY_SCAN_LIMIT})"),
)
def _run_status_value(record: Any) -> str | None:
status = getattr(record, "status", None)
if isinstance(status, RunStatus):
return status.value
return str(status) if status is not None else None
async def _require_successful_source_run(thread_id: str, run_id: str, request: Request) -> RunRecord:
run_mgr = get_run_manager(request)
user_id = await get_current_user(request)
record = await run_mgr.get(run_id, user_id=user_id)
if record is None:
# The run-event journal is the authoritative lookup above. This fallback
# only covers recent in-memory/store hydration gaps for the latest turn.
records = await run_mgr.list_by_thread(thread_id, user_id=user_id, limit=20)
record = next((candidate for candidate in records if getattr(candidate, "run_id", None) == run_id), None)
if record is None:
raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
record_thread_id = getattr(record, "thread_id", None)
if isinstance(record_thread_id, str) and record_thread_id and record_thread_id != thread_id:
raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
if _run_status_value(record) != RunStatus.success.value:
raise HTTPException(status_code=409, detail="Only successful assistant runs can be edited and rerun")
return record
async def _prepare_regenerate_payload(thread_id: str, message_id: str, request: Request) -> RegeneratePrepareResponse:
accessor, latest_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
try:
latest_checkpoint = await accessor.aget(latest_config)
except Exception as exc:
logger.exception("Failed to read latest checkpoint for regenerate thread %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to read latest checkpoint") from exc
latest_checkpoint_id = _checkpoint_configurable(latest_checkpoint).get("checkpoint_id")
if not latest_checkpoint_id:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} has no checkpoint")
messages = _checkpoint_messages(latest_checkpoint)
target_index = next((i for i, message in enumerate(messages) if _message_id(message) == message_id), None)
if target_index is None:
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
target_message = messages[target_index]
if not _is_visible_ai_message(target_message):
raise HTTPException(status_code=409, detail="Only visible assistant messages can be regenerated")
latest_visible_ai = next((message for message in reversed(messages) if _is_visible_ai_message(message)), None)
if _message_id(latest_visible_ai) != message_id:
raise HTTPException(status_code=409, detail="Only the latest assistant message can be regenerated")
previous_human = next((message for message in reversed(messages[:target_index]) if _is_visible_human_message(message)), None)
if previous_human is None:
raise HTTPException(status_code=409, detail="Could not find the user message for this assistant response")
previous_human_id = _message_id(previous_human)
if not previous_human_id:
raise HTTPException(status_code=409, detail="The source user message is missing an id")
base_checkpoint_tuple = await _find_base_checkpoint_before_human(
thread_id,
previous_human_id,
request,
head_checkpoint=latest_checkpoint,
)
target_run_id = await _find_target_run_id(
thread_id,
message_id,
target_message,
previous_human,
request,
)
checkpoint = _checkpoint_response(base_checkpoint_tuple)
metadata = {
"regenerate_from_message_id": message_id,
"regenerate_from_run_id": target_run_id,
"regenerate_checkpoint_id": checkpoint["checkpoint_id"],
}
regenerate_input: dict[str, Any] = {"messages": [_clean_human_message_for_regenerate(previous_human)]}
latest_values = latest_checkpoint.values if isinstance(latest_checkpoint.values, dict) else {}
latest_title = latest_values.get("title")
if isinstance(latest_title, str) and latest_title:
# Regenerate resumes from the checkpoint before the target human turn.
# That checkpoint can predate a manual rename, so replay the current
# title as graph input instead of letting checkpoint rollback restore
# the older automatically generated title (#4457).
regenerate_input["title"] = latest_title
return RegeneratePrepareResponse(
input=regenerate_input,
checkpoint=checkpoint,
metadata=metadata,
target_run_id=target_run_id,
)
async def _prepare_edit_regenerate_payload(
thread_id: str,
human_message_id: str,
replacement_text: str,
request: Request,
) -> EditRegeneratePrepareResponse:
normalized_text = replacement_text.strip()
if not normalized_text:
raise HTTPException(status_code=409, detail="Edited message cannot be empty")
accessor, latest_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
try:
latest_checkpoint = await accessor.aget(latest_config)
except Exception as exc:
logger.exception("Failed to read latest checkpoint for edit replay thread %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to read latest checkpoint") from exc
latest_checkpoint_id = _checkpoint_configurable(latest_checkpoint).get("checkpoint_id")
if not latest_checkpoint_id:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} has no checkpoint")
messages = _checkpoint_messages(latest_checkpoint)
if _has_active_goal(latest_checkpoint):
raise HTTPException(status_code=409, detail="Cannot edit while a goal is active")
_, source_human, _, source_ai, source_message_ids = _latest_editable_turn(messages, human_message_id)
source_text = get_original_user_content_text(_message_content(source_human), _message_additional_kwargs(source_human)).strip()
if normalized_text == source_text:
raise HTTPException(status_code=409, detail="Edited message is unchanged")
source_human_id = _message_id(source_human)
source_ai_id = _message_id(source_ai)
if not source_human_id:
raise HTTPException(status_code=409, detail="The source user message is missing an id")
if not source_ai_id:
raise HTTPException(status_code=409, detail="The source assistant message is missing an id")
base_checkpoint_tuple = await _find_base_checkpoint_before_human(thread_id, source_human_id, request)
target_run_id = await _find_target_run_id(thread_id, source_ai_id, source_ai, source_human, request)
source_record = await _require_successful_source_run(thread_id, target_run_id, request)
checkpoint = _checkpoint_response(base_checkpoint_tuple)
replacement_human_message_id = str(uuid.uuid4())
source_metadata = getattr(source_record, "metadata", None) or {}
existing_group_id = source_metadata.get("edit_version_group_id") if isinstance(source_metadata, dict) else None
# Reserved for future edit-chain grouping across repeated edits of the same
# original prompt; current visibility still keys off regenerate_from_run_id.
edit_version_group_id = existing_group_id if isinstance(existing_group_id, str) and existing_group_id else source_human_id
metadata = {
"replay_kind": "edit",
"regenerate_from_message_id": source_ai_id,
"regenerate_from_run_id": target_run_id,
"regenerate_checkpoint_id": checkpoint["checkpoint_id"],
"edit_from_message_id": source_human_id,
"edit_message_id": replacement_human_message_id,
"edit_version_group_id": edit_version_group_id,
}
return EditRegeneratePrepareResponse(
input={
"messages": [
_clean_human_message_for_edit(
source_human,
replacement_id=replacement_human_message_id,
replacement_text=normalized_text,
)
]
},
checkpoint=checkpoint,
metadata=metadata,
target_run_id=target_run_id,
replacement_human_message_id=replacement_human_message_id,
source_message_ids=source_message_ids,
)
async def _default_history_hidden_run_ids(run_mgr: Any, thread_id: str, *, user_id: str | None) -> set[str]:
superseded_run_ids = await run_mgr.list_successful_regenerate_sources(thread_id, user_id=user_id)
edit_visibility = await run_mgr.list_edit_replay_visibility(thread_id, user_id=user_id)
return set(superseded_run_ids) | set(edit_visibility.hidden_source_run_ids) | set(edit_visibility.hidden_attempt_run_ids)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/{thread_id}/runs/regenerate/prepare", response_model=RegeneratePrepareResponse)
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def prepare_regenerate_run(
thread_id: str,
body: RegeneratePrepareRequest,
request: Request,
) -> RegeneratePrepareResponse:
"""Prepare input and checkpoint for regenerating the latest assistant turn."""
return await _prepare_regenerate_payload(thread_id, body.message_id, request)
@router.post("/{thread_id}/runs/edit-regenerate/prepare", response_model=EditRegeneratePrepareResponse)
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def prepare_edit_regenerate_run(
thread_id: str,
body: EditRegeneratePrepareRequest,
request: Request,
) -> EditRegeneratePrepareResponse:
"""Prepare input and checkpoint for editing then rerunning the latest user turn."""
return await _prepare_edit_regenerate_payload(thread_id, body.human_message_id, body.replacement_text, request)
@router.post("/{thread_id}/runs", response_model=RunResponse)
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -> RunResponse:
"""Create a background run (returns immediately)."""
record = await start_run(body, thread_id, request)
return _record_to_response(record)
@router.post("/{thread_id}/runs/stream")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -> StreamingResponse:
"""Create a run and stream events via SSE.
The response includes a ``Content-Location`` header with the run's
resource URL, matching the LangGraph Platform protocol. The
``useStream`` React hook uses this to extract run metadata.
"""
bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request)
record = await start_run(body, thread_id, request)
return StreamingResponse(
sse_consumer(bridge, record, request, run_mgr),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
# LangGraph Platform includes run metadata in this header.
# The SDK uses a greedy regex to extract the run id from this path,
# so it must point at the canonical run resource without extra suffixes.
"Content-Location": f"/api/threads/{thread_id}/runs/{record.run_id}",
},
)
@router.post("/{thread_id}/runs/wait", response_model=dict)
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict:
"""Create a run and block until it completes, returning the final state."""
bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request)
record = await start_run(body, thread_id, request)
completed = True
if record.task is not None:
completed = await wait_for_run_completion(bridge, record, request, run_mgr)
if completed:
try:
accessor, config = build_checkpoint_state_accessor(
request,
thread_id=thread_id,
assistant_id=body.assistant_id,
)
snapshot = await accessor.aget(config)
snapshot_config = snapshot.config or {}
if snapshot_config.get("configurable", {}).get("checkpoint_id"):
return serialize_channel_values_for_api(snapshot.values)
except Exception:
logger.exception("Failed to fetch final state for run %s", record.run_id)
return {"status": record.status.value, "error": record.error}
@router.get("/{thread_id}/runs", response_model=list[RunResponse])
@require_permission("runs", "read", owner_check=True)
async def list_runs(thread_id: str, request: Request) -> list[RunResponse]:
"""List all runs for a thread."""
run_mgr = get_run_manager(request)
user_id = await get_current_user(request)
records = await run_mgr.list_by_thread(thread_id, user_id=user_id)
return [_record_to_response(r) for r in records]
@router.get("/{thread_id}/runs/{run_id}", response_model=RunResponse)
@require_permission("runs", "read", owner_check=True)
async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
"""Get details of a specific run."""
run_mgr = get_run_manager(request)
user_id = await get_current_user(request)
record = await run_mgr.get(run_id, user_id=user_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return _record_to_response(record)
@router.post("/{thread_id}/runs/{run_id}/cancel")
@require_permission("runs", "cancel", owner_check=True, require_existing=True)
async def cancel_run(
thread_id: str,
run_id: str,
request: Request,
wait: bool = Query(default=False, description="Block until run completes after cancel"),
action: Literal["interrupt", "rollback"] = Query(default="interrupt", description="Cancel action"),
) -> Response:
"""Cancel a running or pending run.
- action=interrupt: Stop execution, keep current checkpoint (can be resumed)
- action=rollback: Stop execution, revert to pre-run checkpoint state
- wait=true: Block until the run fully stops, return 204
- wait=false: Return immediately with 202
In multi-worker deployments, a cancel landing on a non-owning worker
can take over the run when the owner's lease has expired. When the
lease is still valid a 409 + ``Retry-After`` header is returned.
"""
run_mgr = get_run_manager(request)
record = await run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
outcome = await run_mgr.cancel(run_id, action=action)
# Success paths — the run was either cancelled locally or taken over
# from a dead worker.
if outcome in (CancelOutcome.cancelled, CancelOutcome.taken_over):
if wait and record.task is not None:
try:
await record.task
except asyncio.CancelledError:
pass
return Response(status_code=204)
return Response(status_code=202)
if outcome == CancelOutcome.lease_valid_elsewhere:
await _raise_lease_valid_elsewhere(run_id, run_mgr, record)
# not_cancellable, not_active_locally, unknown
raise HTTPException(status_code=409, detail=_cancel_conflict_detail(run_id, record))
@router.get("/{thread_id}/runs/{run_id}/join")
@require_permission("runs", "read", owner_check=True)
async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingResponse:
"""Join an existing run's SSE stream."""
run_mgr = get_run_manager(request)
record = await run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
bridge = get_stream_bridge(request)
if record.store_only and not bridge.supports_cross_process:
raise HTTPException(status_code=409, detail=f"Run {run_id} is not active on this worker and cannot be streamed")
return StreamingResponse(
sse_consumer(bridge, record, request, run_mgr),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# Register GET and POST as separate routes so each method gets a unique OpenAPI
# operationId. ``api_route(methods=["GET", "POST"])`` shares one route registration
# across both methods, which makes FastAPI emit the same ``operationId`` twice and
# warn about a duplicate operation id during OpenAPI generation.
@router.get("/{thread_id}/runs/{run_id}/stream", response_model=None)
@router.post("/{thread_id}/runs/{run_id}/stream", response_model=None)
@require_permission("runs", "read", owner_check=True)
async def stream_existing_run(
thread_id: str,
run_id: str,
request: Request,
action: Literal["interrupt", "rollback"] | None = Query(default=None, description="Cancel action"),
wait: int = Query(default=0, description="Block until cancelled (1) or return immediately (0)"),
):
"""Join an existing run's SSE stream (GET), or cancel-then-stream (POST).
The LangGraph SDK's ``joinStream`` and ``useStream`` stop button both use
``POST`` to this endpoint. When ``action=interrupt`` or ``action=rollback``
is present the run is cancelled first; the response then streams any
remaining buffered events so the client observes a clean shutdown.
"""
run_mgr = get_run_manager(request)
record = await run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
bridge = get_stream_bridge(request)
if record.store_only and action is None and not bridge.supports_cross_process:
raise HTTPException(status_code=409, detail=f"Run {run_id} is not active on this worker and cannot be streamed")
# Cancel if an action was requested (stop-button / interrupt flow)
if action is not None:
outcome = await run_mgr.cancel(run_id, action=action)
if outcome == CancelOutcome.taken_over:
# The run was on another worker and is now marked ``error`` in the
# store. There is no local stream to drain — return immediately so
# the client doesn't hang on an SSE subscription this worker can
# never serve.
return Response(status_code=202)
if outcome != CancelOutcome.cancelled:
if outcome == CancelOutcome.lease_valid_elsewhere:
await _raise_lease_valid_elsewhere(run_id, run_mgr, record)
raise HTTPException(status_code=409, detail=_cancel_conflict_detail(run_id, record))
if wait and record.task is not None:
try:
await record.task
except (asyncio.CancelledError, Exception):
pass
return Response(status_code=204)
return StreamingResponse(
sse_consumer(bridge, record, request, run_mgr),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ---------------------------------------------------------------------------
# Messages / Events / Token usage endpoints
# ---------------------------------------------------------------------------
@router.get("/{thread_id}/messages")
@require_permission("runs", "read", owner_check=True)
async def list_thread_messages(
thread_id: str,
request: Request,
limit: int = Query(default=50, ge=1, le=200),
before_seq: int | None = Query(default=None, ge=1),
after_seq: int | None = Query(default=None, ge=1),
) -> list[dict]:
"""Return displayable messages for a thread (across all runs), with feedback attached."""
# Resolve the caller once; it is needed both to scope the feedback query
# below and to list the thread's runs for turn-duration injection.
user_id = await get_current_user(request)
run_mgr = get_run_manager(request)
hidden_run_ids = await _default_history_hidden_run_ids(run_mgr, thread_id, user_id=user_id)
messages, _ = await _scan_visible_thread_messages(
thread_id,
limit=limit,
before_seq=before_seq,
after_seq=after_seq,
request=request,
user_id=user_id,
hidden_run_ids=hidden_run_ids,
include_middleware=True,
include_extra=False,
batch_size=THREAD_MESSAGE_LEGACY_SCAN_BATCH,
)
# Find the last AI message per run_id. AI messages are persisted by
# RunJournal with event_type "llm.ai.response" (see runtime/journal.py);
# the event store returns that value verbatim, so match on it here.
last_ai_per_run: dict[str, int] = {} # run_id -> index in messages list
for i, msg in enumerate(messages):
if msg.get("event_type") == "llm.ai.response":
last_ai_per_run[msg["run_id"]] = i
# Attach feedback to the last AI message of each run. Only query when there
# is an AI message to attach it to — threads with no completed AI turn yet
# would otherwise pay for a grouped feedback lookup whose result is unused.
feedback_map = {}
if last_ai_per_run:
feedback_service = get_feedback_service(request)
feedback_map = await feedback_service.latest_per_run_in_thread(thread_id, user_id=user_id)
last_ai_indices = set(last_ai_per_run.values())
for i, msg in enumerate(messages):
if i in last_ai_indices:
run_id = msg["run_id"]
fb = feedback_map.get(run_id)
msg["feedback"] = (
{
"feedback_id": fb.feedback_id,
"rating": fb.rating,
"comment": fb.comment,
"tags": list(fb.tags),
}
if fb
else None
)
else:
msg["feedback"] = None
runs = await run_mgr.list_by_thread(thread_id, user_id=user_id)
run_durations = compute_run_durations(runs)
if run_durations:
for msg in messages:
content = msg.get("content", {})
if isinstance(content, dict) and content.get("type") == "ai":
rid = msg.get("run_id")
if rid and rid in run_durations:
if "additional_kwargs" not in content:
content["additional_kwargs"] = {}
content["additional_kwargs"]["turn_duration"] = run_durations[rid]
return messages
async def _scan_visible_thread_messages(
thread_id: str,
*,
limit: int,
before_seq: int | None,
after_seq: int | None,
request: Request,
user_id: str | None,
hidden_run_ids: set[str],
include_middleware: bool,
include_extra: bool,
batch_size: int,
) -> tuple[list[dict[str, Any]], bool]:
"""Scan raw message rows until ``limit`` visible rows survive filtering."""
event_store = get_run_event_store(request)
needed = limit + 1 if include_extra else limit
if after_seq is not None:
visible: list[dict[str, Any]] = []
scan_after = after_seq
while len(visible) < needed:
raw = await event_store.list_messages(
thread_id,
limit=batch_size,
after_seq=scan_after,
user_id=user_id,
)
if not raw:
break
_validate_message_scan_rows(raw, thread_id=thread_id, scan_before=None, scan_after=scan_after)
reached_before_bound = False
for row in raw:
if before_seq is not None and row["seq"] >= before_seq:
reached_before_bound = True
break
if (not include_middleware and _is_thread_history_hidden_message_row(row)) or row.get("run_id") in hidden_run_ids:
continue
visible.append(row)
if len(visible) == needed:
break
next_scan_after = max(row["seq"] for row in raw)
if next_scan_after <= scan_after:
_raise_non_advancing_message_scan(thread_id=thread_id, scan_before=None, scan_after=scan_after, next_cursor=next_scan_after, row_count=len(raw))
scan_after = next_scan_after
if reached_before_bound or len(raw) < batch_size:
break
has_more = len(visible) > limit
return visible[:limit], has_more
visible_desc: list[dict[str, Any]] = []
scan_before = before_seq
while len(visible_desc) < needed:
raw = await event_store.list_messages(
thread_id,
limit=batch_size,
before_seq=scan_before,
user_id=user_id,
)
if not raw:
break
_validate_message_scan_rows(raw, thread_id=thread_id, scan_before=scan_before, scan_after=None)
for row in reversed(raw):
if (not include_middleware and _is_thread_history_hidden_message_row(row)) or row.get("run_id") in hidden_run_ids:
continue
visible_desc.append(row)
if len(visible_desc) == needed:
break
next_scan_before = min(row["seq"] for row in raw)
if scan_before is not None and next_scan_before >= scan_before:
_raise_non_advancing_message_scan(thread_id=thread_id, scan_before=scan_before, scan_after=None, next_cursor=next_scan_before, row_count=len(raw))
scan_before = next_scan_before
if len(raw) < batch_size:
break
has_more = len(visible_desc) > limit
return list(reversed(visible_desc[:limit])), has_more
def _validate_message_scan_rows(
rows: list[dict[str, Any]],
*,
thread_id: str,
scan_before: int | None,
scan_after: int | None,
) -> None:
invalid_seq_rows = [row for row in rows if not isinstance(row.get("seq"), int)]
if invalid_seq_rows:
logger.error(
"Thread message scan found rows without sequence values: thread_id=%s scan_before=%s scan_after=%s row_count=%d invalid_count=%d",
thread_id,
scan_before,
scan_after,
len(rows),
len(invalid_seq_rows),
)
raise RuntimeError("Run event message rows are missing sequence values")
def _raise_non_advancing_message_scan(
*,
thread_id: str,
scan_before: int | None,
scan_after: int | None,
next_cursor: int,
row_count: int,
) -> None:
logger.error(
"Thread message scan cursor did not advance: thread_id=%s scan_before=%s scan_after=%s next_cursor=%s row_count=%d",
thread_id,
scan_before,
scan_after,
next_cursor,
row_count,
)
raise RuntimeError("Run event message scan did not advance its cursor")
async def _scan_thread_message_page(
thread_id: str,
*,
limit: int,
before_seq: int | None,
request: Request,
user_id: str | None,
) -> tuple[list[dict[str, Any]], bool]:
"""Select the newest ``limit + 1`` page-eligible rows before a cursor."""
run_mgr = get_run_manager(request)
hidden_run_ids = await _default_history_hidden_run_ids(run_mgr, thread_id, user_id=user_id)
return await _scan_visible_thread_messages(
thread_id,
limit=limit,
before_seq=before_seq,
after_seq=None,
request=request,
user_id=user_id,
hidden_run_ids=hidden_run_ids,
include_middleware=False,
include_extra=True,
batch_size=THREAD_MESSAGE_PAGE_SCAN_BATCH,
)
async def _enrich_thread_message_page(
thread_id: str,
rows: list[dict[str, Any]],
*,
request: Request,
user_id: str | None,
) -> list[dict[str, Any]]:
"""Attach run-scoped duration and feedback without mutating store rows."""
data = deepcopy(rows)
if not data:
return data
run_ids = {row["run_id"] for row in data if isinstance(row.get("run_id"), str)}
run_mgr = get_run_manager(request)
records = await run_mgr.get_many_by_thread(thread_id, run_ids, user_id=user_id)
run_durations = compute_run_durations(records.values())
event_store = get_run_event_store(request)
last_ai_seq_by_run = await event_store.get_last_visible_ai_seq_by_run(thread_id, run_ids, user_id=user_id)
feedback_map = {}
feedback_run_ids = {run_id for row in data if isinstance((run_id := row.get("run_id")), str) and row.get("seq") == last_ai_seq_by_run.get(run_id)}
if feedback_run_ids:
feedback_service = get_feedback_service(request)
feedback_map = await feedback_service.latest_for_runs(thread_id, feedback_run_ids, user_id=user_id)
for row in data:
run_id = row.get("run_id")
row["feedback"] = None
if row.get("seq") == last_ai_seq_by_run.get(run_id):
feedback = feedback_map.get(run_id)
if feedback:
row["feedback"] = {
"feedback_id": feedback.feedback_id,
"rating": feedback.rating,
"comment": feedback.comment,
"tags": list(feedback.tags),
}
content = row.get("content")
if isinstance(content, dict) and content.get("type") == "ai" and run_id in run_durations:
content.setdefault("additional_kwargs", {})["turn_duration"] = run_durations[run_id]
return data
@router.get("/{thread_id}/messages/page", response_model=ThreadMessagesPageResponse)
@require_permission("runs", "read", owner_check=True)
async def list_thread_messages_page(
thread_id: str,
request: Request,
limit: int = Query(default=50, ge=1, le=200),
before_seq: int | None = Query(default=None, ge=1),
) -> ThreadMessagesPageResponse:
"""Return a backward page ordered by the thread-global event sequence."""
if "after_seq" in request.query_params:
raise HTTPException(status_code=422, detail="after_seq is not supported by this backward-only endpoint")
user_id = await get_current_user(request)
rows, has_more = await _scan_thread_message_page(
thread_id,
limit=limit,
before_seq=before_seq,
request=request,
user_id=user_id,
)
data = await _enrich_thread_message_page(thread_id, rows, request=request, user_id=user_id)
return ThreadMessagesPageResponse(
data=data,
has_more=has_more,
next_before_seq=data[0]["seq"] if has_more else None,
)
@router.get("/{thread_id}/runs/{run_id}/messages")
@require_permission("runs", "read", owner_check=True)
async def list_run_messages(
thread_id: str,
run_id: str,
request: Request,
limit: int = Query(default=50, le=200, ge=1),
before_seq: int | None = Query(default=None, ge=1),
after_seq: int | None = Query(default=None, ge=1),
) -> dict:
"""Return paginated messages for a specific run.
Response: { data: [...], has_more: bool }
"""
event_store = get_run_event_store(request)
rows = await event_store.list_messages_by_run(
thread_id,
run_id,
limit=limit + 1,
before_seq=before_seq,
after_seq=after_seq,
)
data, has_more = trim_run_message_page(rows, limit=limit, after_seq=after_seq)
if data:
run_mgr = get_run_manager(request)
record = await run_mgr.get(run_id)
if record:
durations = compute_run_durations([record])
duration = durations.get(run_id)
if duration is not None:
for msg in reversed(data):
content = msg.get("content")
metadata = msg.get("metadata", {})
is_middleware = str(metadata.get("caller", "")).startswith("middleware:")
if isinstance(content, dict) and content.get("type") == "ai" and not is_middleware:
if "additional_kwargs" not in content:
content["additional_kwargs"] = {}
content["additional_kwargs"]["turn_duration"] = duration
return {"data": data, "has_more": has_more}
@router.get("/{thread_id}/runs/{run_id}/events")
@require_permission("runs", "read", owner_check=True)
async def list_run_events(
thread_id: str,
run_id: str,
request: Request,
event_types: str | None = Query(default=None),
task_id: str | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=2000),
after_seq: int | None = Query(default=None, ge=1),
) -> list[dict]:
"""Return the full event stream for a run (debug/audit).
``task_id`` + ``after_seq`` let the subtask card page through one subagent
task's persisted steps without the run-wide ``limit`` truncating the tail (#3779).
"""
event_store = get_run_event_store(request)
types = event_types.split(",") if event_types else None
return await event_store.list_events(thread_id, run_id, event_types=types, task_id=task_id, limit=limit, after_seq=after_seq)
@router.get("/{thread_id}/runs/{run_id}/workspace-changes")
@require_permission("runs", "read", owner_check=True)
async def get_run_workspace_changes(
thread_id: str,
run_id: str,
request: Request,
include_files: bool = Query(default=True),
include_diff: bool = Query(default=True),
) -> dict:
"""Return workspace/output file changes recorded for one run."""
event_store = get_run_event_store(request)
return await get_workspace_changes_response(
event_store,
thread_id,
run_id,
include_files=include_files,
include_diff=include_diff,
)
@router.get("/{thread_id}/token-usage", response_model=ThreadTokenUsageResponse)
@require_permission("threads", "read", owner_check=True)
async def thread_token_usage(
thread_id: str,
request: Request,
include_active: bool = Query(default=False, description="Include running run progress snapshots"),
) -> ThreadTokenUsageResponse:
"""Thread-level token usage aggregation."""
run_store = get_run_store(request)
if include_active:
agg = await run_store.aggregate_tokens_by_thread(thread_id, include_active=True)
else:
agg = await run_store.aggregate_tokens_by_thread(thread_id)
return ThreadTokenUsageResponse(thread_id=thread_id, **agg)