Files
deer-flow/backend/app/gateway/routers/langgraph/runs.py
T
rayhpeng 5f2f1941e9 refactor(routers): reorganize routers with new langgraph/ subdirectory
Restructure app/gateway/routers/:
- Add langgraph/ subdirectory for LangGraph-related endpoints:
  - threads.py - thread management
  - runs.py - run execution and streaming
  - feedback.py - feedback endpoints
  - suggestions.py - follow-up suggestions

Remove old standalone routers:
- threads.py → langgraph/threads.py
- thread_runs.py → langgraph/runs.py
- runs.py (stateless) → langgraph/runs.py
- feedback.py → langgraph/feedback.py

Update existing routers:
- memory.py, uploads.py, artifacts.py, suggestions.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 11:28:26 +08:00

502 lines
18 KiB
Python

"""LangGraph-compatible runs endpoints backed by RunsFacade."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field
from app.plugins.auth.security.actor_context import bind_request_actor_context
from app.gateway.services.runs.facade_factory import build_runs_facade_from_request
from app.gateway.services.runs.input import (
AdaptedRunRequest,
RunSpecBuilder,
UnsupportedRunFeatureError,
adapt_create_run_request,
adapt_create_stream_request,
adapt_create_wait_request,
adapt_join_stream_request,
adapt_join_wait_request,
)
from deerflow.runtime.runs.types import RunRecord, RunSpec
from deerflow.runtime.stream_bridge import JSONValue, StreamEvent
router = APIRouter(tags=["runs"])
class RunCreateRequest(BaseModel):
assistant_id: str | None = Field(default=None, description="Agent / assistant to use")
follow_up_to_run_id: str | None = Field(default=None, description="Lineage link to the prior run")
input: dict[str, JSONValue] | None = Field(default=None, description="Graph input (e.g. {messages: [...]})")
command: dict[str, JSONValue] | None = Field(default=None, description="LangGraph Command")
metadata: dict[str, JSONValue] | None = Field(default=None, description="Run metadata")
config: dict[str, JSONValue] | None = Field(default=None, description="RunnableConfig overrides")
context: dict[str, JSONValue] | None = Field(default=None, description="DeerFlow context overrides (model_name, thinking_enabled, etc.)")
webhook: str | None = Field(default=None, description="Completion callback URL")
checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint")
checkpoint: dict[str, JSONValue] | None = Field(default=None, description="Full checkpoint object")
interrupt_before: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt before")
interrupt_after: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt after")
stream_mode: list[str] | str | None = Field(default=None, description="Stream mode(s)")
stream_subgraphs: bool = Field(default=False, description="Include subgraph events")
stream_resumable: bool | None = Field(default=None, description="SSE resumable mode")
on_disconnect: Literal["cancel", "continue"] = Field(default="cancel", description="Behaviour on SSE disconnect")
on_completion: Literal["delete", "keep"] = Field(default="keep", description="Delete temp thread on completion")
multitask_strategy: Literal["reject", "rollback", "interrupt", "enqueue"] = Field(default="reject", description="Concurrency strategy")
after_seconds: float | None = Field(default=None, description="Delayed execution")
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
class RunResponse(BaseModel):
run_id: str
thread_id: str
assistant_id: str | None = None
status: str
metadata: dict[str, JSONValue] = Field(default_factory=dict)
multitask_strategy: str = "reject"
created_at: str = ""
updated_at: str = ""
class RunDeleteResponse(BaseModel):
deleted: bool
class RunMessageResponse(BaseModel):
run_id: str
content: JSONValue
metadata: dict[str, JSONValue] = Field(default_factory=dict)
created_at: str
seq: int
class RunMessagesResponse(BaseModel):
data: list[RunMessageResponse]
hasMore: bool = False
def format_sse(event: str, data: JSONValue, *, event_id: str | None = None) -> str:
"""Format a single SSE frame."""
payload = json.dumps(data, default=str, ensure_ascii=False)
parts = [f"event: {event}", f"data: {payload}"]
if event_id:
parts.append(f"id: {event_id}")
parts.append("")
parts.append("")
return "\n".join(parts)
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,
metadata=record.metadata,
multitask_strategy=record.multitask_strategy,
created_at=record.created_at,
updated_at=record.updated_at,
)
def _trim_paginated_rows(
rows: list[dict],
*,
limit: int,
after_seq: int | None,
) -> tuple[list[dict], bool]:
has_more = len(rows) > limit
if not has_more:
return rows, False
if after_seq is not None:
return rows[:limit], True
return rows[-limit:], True
def _event_to_run_message(event: dict) -> RunMessageResponse:
return RunMessageResponse(
run_id=str(event["run_id"]),
content=event.get("content"),
metadata=dict(event.get("metadata") or {}),
created_at=str(event.get("created_at") or ""),
seq=int(event["seq"]),
)
async def _sse_consumer(
stream: AsyncIterator[StreamEvent],
request: Request,
*,
cancel_on_disconnect: bool,
cancel_run,
run_id: str,
) -> AsyncIterator[str]:
try:
async for event in stream:
if await request.is_disconnected():
break
if event.event == "__heartbeat__":
yield ": heartbeat\n\n"
continue
if event.event == "__end__":
yield format_sse("end", None, event_id=event.id or None)
return
if event.event == "__cancelled__":
yield format_sse("cancel", None, event_id=event.id or None)
return
yield format_sse(event.event, event.data, event_id=event.id or None)
finally:
if cancel_on_disconnect:
await cancel_run(run_id)
def _get_run_event_store(request: Request):
event_store = getattr(request.app.state, "run_event_store", None)
if event_store is None:
raise HTTPException(status_code=503, detail="Run event store not available")
return event_store
@router.get("/{thread_id}/runs", response_model=list[RunResponse])
async def list_runs(
thread_id: str,
request: Request,
limit: int = 100,
offset: int = 0,
status: str | None = None,
) -> list[RunResponse]:
# Accepted for API compatibility; field projection is not implemented yet.
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
records = await facade.list_runs(thread_id)
if status is not None:
records = [record for record in records if record.status == status]
records = records[offset : offset + limit]
return [_record_to_response(record) for record in records]
@router.get("/{thread_id}/runs/{run_id}", response_model=RunResponse)
async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.get_run(run_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.get("/{thread_id}/runs/{run_id}/messages", response_model=RunMessagesResponse)
async def run_messages(
thread_id: str,
run_id: str,
request: Request,
limit: int = 50,
before_seq: int | None = None,
after_seq: int | None = None,
) -> RunMessagesResponse:
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.get_run(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
event_store = _get_run_event_store(request)
with bind_request_actor_context(request):
rows = await event_store.list_messages_by_run(
thread_id,
run_id,
limit=limit + 1,
before_seq=before_seq,
after_seq=after_seq,
)
page, has_more = _trim_paginated_rows(rows, limit=limit, after_seq=after_seq)
return RunMessagesResponse(data=[_event_to_run_message(row) for row in page], hasMore=has_more)
def _build_spec(
*,
adapted: AdaptedRunRequest,
) -> RunSpec:
try:
return RunSpecBuilder().build(adapted)
except UnsupportedRunFeatureError as exc:
raise HTTPException(status_code=501, detail=str(exc)) from exc
@router.post("/{thread_id}/runs", response_model=RunResponse)
async def create_run(
thread_id: str,
body: RunCreateRequest,
request: Request,
) -> Response:
adapted = adapt_create_run_request(
thread_id=thread_id,
body=body.model_dump(),
headers=dict(request.headers),
query=dict(request.query_params),
)
spec = _build_spec(adapted=adapted)
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.create_background(spec)
return Response(
content=_record_to_response(record).model_dump_json(),
media_type="application/json",
headers={"Content-Location": f"/api/threads/{thread_id}/runs/{record.run_id}"},
)
@router.post("/{thread_id}/runs/stream")
async def stream_run(
thread_id: str,
body: RunCreateRequest,
request: Request,
) -> StreamingResponse:
adapted = adapt_create_stream_request(
thread_id=thread_id,
body=body.model_dump(),
headers=dict(request.headers),
query=dict(request.query_params),
)
spec = _build_spec(adapted=adapted)
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record, stream = await facade.create_and_stream(spec)
return StreamingResponse(
_sse_consumer(
stream,
request,
cancel_on_disconnect=spec.on_disconnect == "cancel",
cancel_run=facade.cancel,
run_id=record.run_id,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"Content-Location": f"/api/threads/{thread_id}/runs/{record.run_id}",
},
)
@router.post("/{thread_id}/runs/wait")
async def wait_run(
thread_id: str,
body: RunCreateRequest,
request: Request,
) -> Response:
adapted = adapt_create_wait_request(
thread_id=thread_id,
body=body.model_dump(),
headers=dict(request.headers),
query=dict(request.query_params),
)
spec = _build_spec(adapted=adapted)
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record, result = await facade.create_and_wait(spec)
return Response(
content=json.dumps(result, default=str, ensure_ascii=False),
media_type="application/json",
headers={"Content-Location": f"/api/threads/{thread_id}/runs/{record.run_id}"},
)
@router.post("/runs", response_model=RunResponse)
async def create_stateless_run(body: RunCreateRequest, request: Request) -> Response:
adapted = adapt_create_run_request(
thread_id=None,
body=body.model_dump(),
headers=dict(request.headers),
query=dict(request.query_params),
)
spec = _build_spec(adapted=adapted)
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.create_background(spec)
return Response(
content=_record_to_response(record).model_dump_json(),
media_type="application/json",
headers={"Content-Location": f"/api/threads/{record.thread_id}/runs/{record.run_id}"},
)
@router.post("/runs/stream")
async def create_stateless_stream_run(body: RunCreateRequest, request: Request) -> StreamingResponse:
adapted = adapt_create_stream_request(
thread_id=None,
body=body.model_dump(),
headers=dict(request.headers),
query=dict(request.query_params),
)
spec = _build_spec(adapted=adapted)
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record, stream = await facade.create_and_stream(spec)
return StreamingResponse(
_sse_consumer(
stream,
request,
cancel_on_disconnect=spec.on_disconnect == "cancel",
cancel_run=facade.cancel,
run_id=record.run_id,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"Content-Location": f"/api/threads/{record.thread_id}/runs/{record.run_id}",
},
)
@router.post("/runs/wait")
async def wait_stateless_run(body: RunCreateRequest, request: Request) -> Response:
adapted = adapt_create_wait_request(
thread_id=None,
body=body.model_dump(),
headers=dict(request.headers),
query=dict(request.query_params),
)
spec = _build_spec(adapted=adapted)
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record, result = await facade.create_and_wait(spec)
return Response(
content=json.dumps(result, default=str, ensure_ascii=False),
media_type="application/json",
headers={"Content-Location": f"/api/threads/{record.thread_id}/runs/{record.run_id}"},
)
@router.api_route("/{thread_id}/runs/{run_id}/stream", methods=["GET", "POST"], response_model=None)
async def stream_existing_run(
thread_id: str,
run_id: str,
request: Request,
action: Literal["interrupt", "rollback"] | None = None,
wait: bool = False,
cancel_on_disconnect: bool = False,
stream_mode: str | None = None,
) -> StreamingResponse | Response:
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.get_run(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
if action is not None:
with bind_request_actor_context(request):
cancelled = await facade.cancel(run_id, action=action)
if not cancelled:
raise HTTPException(status_code=409, detail=f"Run {run_id} is not cancellable")
if wait:
with bind_request_actor_context(request):
await facade.join_wait(run_id)
return Response(status_code=204)
adapted = adapt_join_stream_request(
thread_id=thread_id,
run_id=run_id,
headers=dict(request.headers),
query=dict(request.query_params),
)
with bind_request_actor_context(request):
stream = await facade.join_stream(run_id, last_event_id=adapted.last_event_id)
return StreamingResponse(
_sse_consumer(
stream,
request,
cancel_on_disconnect=cancel_on_disconnect,
cancel_run=facade.cancel,
run_id=run_id,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/{thread_id}/runs/{run_id}/join")
async def join_existing_run(
thread_id: str,
run_id: str,
request: Request,
cancel_on_disconnect: bool = False,
) -> JSONValue:
# Accepted for API compatibility; current join_wait path does not change
# behavior based on client disconnect.
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.get_run(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
adapted = adapt_join_wait_request(
thread_id=thread_id,
run_id=run_id,
headers=dict(request.headers),
query=dict(request.query_params),
)
with bind_request_actor_context(request):
return await facade.join_wait(run_id, last_event_id=adapted.last_event_id)
@router.post("/{thread_id}/runs/{run_id}/cancel")
async def cancel_existing_run(
thread_id: str,
run_id: str,
request: Request,
wait: bool = False,
action: Literal["interrupt", "rollback"] = "interrupt",
) -> JSONValue:
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.get_run(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
with bind_request_actor_context(request):
cancelled = await facade.cancel(run_id, action=action)
if not cancelled:
raise HTTPException(status_code=409, detail=f"Run {run_id} is not cancellable")
if wait:
with bind_request_actor_context(request):
return await facade.join_wait(run_id)
return {}
@router.delete("/{thread_id}/runs/{run_id}", response_model=RunDeleteResponse)
async def delete_run(
thread_id: str,
run_id: str,
request: Request,
) -> RunDeleteResponse:
facade = build_runs_facade_from_request(request)
with bind_request_actor_context(request):
record = await facade.get_run(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
with bind_request_actor_context(request):
deleted = await facade.delete_run(run_id)
return RunDeleteResponse(deleted=deleted)