mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 13:53:15 +00:00
506 lines
19 KiB
Python
506 lines
19 KiB
Python
"""Dashboard thread list/detail/run/stream endpoints backed by LangGraph."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from langgraph_sdk.errors import InternalServerError
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..utils.thread_ops import is_thread_active, langgraph_client, queue_message_for_thread
|
|
from .message_adapter import state_messages_to_ui
|
|
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
|
|
from .profiles import get_profile, get_valid_access_token
|
|
from .user_mappings import email_for_login
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ASSISTANT_ID = "agent"
|
|
_DASHBOARD_SOURCE = "dashboard"
|
|
_DASHBOARD_STREAM_MODES: tuple[str, ...] = ("values", "updates", "messages-tuple")
|
|
# Sources whose threads should surface in the Agents UI (besides "dashboard").
|
|
_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear")
|
|
|
|
|
|
def _agent_version_metadata() -> dict[str, str]:
|
|
revision = os.environ.get("LANGCHAIN_REVISION_ID")
|
|
return {"LANGSMITH_AGENT_VERSION": revision} if revision else {}
|
|
|
|
|
|
async def _resolve_run_email(login: str, profile: dict[str, Any]) -> str | None:
|
|
"""Email used for GitHub/LangSmith auth on a run.
|
|
|
|
Prefers the admin/self GitHub→email mapping (the work email known to
|
|
the org) over the OAuth profile email, which may be a personal account
|
|
that isn't an org member.
|
|
"""
|
|
mapped = await email_for_login(login)
|
|
return mapped or profile.get("email")
|
|
|
|
|
|
class ThreadCreateBody(BaseModel):
|
|
prompt: str = Field(min_length=1, max_length=20_000)
|
|
repo: str | None = None
|
|
model_id: str | None = None
|
|
effort: str | None = None
|
|
|
|
|
|
class ThreadMessageBody(BaseModel):
|
|
content: str = Field(min_length=1, max_length=20_000)
|
|
model_id: str | None = None
|
|
effort: str | None = None
|
|
|
|
|
|
def _normalize_model_choice(
|
|
model_id: str | None, effort: str | None
|
|
) -> tuple[str | None, str | None]:
|
|
if not isinstance(model_id, str) or model_id not in SUPPORTED_MODEL_IDS:
|
|
return None, None
|
|
if not isinstance(effort, str) or not model_supports_effort(model_id, effort):
|
|
return None, None
|
|
return model_id, effort
|
|
|
|
|
|
def _now_ms() -> int:
|
|
return int(datetime.now(UTC).timestamp() * 1000)
|
|
|
|
|
|
def _parse_repo(full_name: str | None) -> dict[str, str] | None:
|
|
if not isinstance(full_name, str):
|
|
return None
|
|
parts = full_name.strip().split("/", 1)
|
|
if len(parts) != 2:
|
|
return None
|
|
owner, name = parts[0].strip(), parts[1].strip()
|
|
if not owner or not name:
|
|
return None
|
|
return {"owner": owner, "name": name}
|
|
|
|
|
|
async def _ensure_dashboard_github_token(login: str) -> None:
|
|
token = await get_valid_access_token(login)
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
|
|
|
|
def _thread_owner_login(metadata: dict[str, Any]) -> str | None:
|
|
login = metadata.get("github_login")
|
|
return login.strip() if isinstance(login, str) and login.strip() else None
|
|
|
|
|
|
def _thread_owner_email(metadata: dict[str, Any]) -> str | None:
|
|
email = metadata.get("triggering_user_email")
|
|
return email.strip().lower() if isinstance(email, str) and email.strip() else None
|
|
|
|
|
|
def _thread_source(metadata: dict[str, Any]) -> str:
|
|
source = metadata.get("source")
|
|
return source if isinstance(source, str) and source else _DASHBOARD_SOURCE
|
|
|
|
|
|
def _user_owns_thread(metadata: dict[str, Any], login: str, email: str | None) -> bool:
|
|
if _thread_source(metadata) not in _SURFACED_SOURCES:
|
|
return False
|
|
if _thread_owner_login(metadata) == login:
|
|
return True
|
|
if email and _thread_owner_email(metadata) == email.strip().lower():
|
|
return True
|
|
return False
|
|
|
|
|
|
def _assert_thread_owner(metadata: dict[str, Any], login: str, email: str | None = None) -> None:
|
|
if not _user_owns_thread(metadata, login, email):
|
|
raise HTTPException(404, "thread not found")
|
|
|
|
|
|
def _metadata_repo(metadata: dict[str, Any]) -> tuple[str, str, str]:
|
|
owner = metadata.get("repo_owner")
|
|
name = metadata.get("repo_name")
|
|
if isinstance(owner, str) and isinstance(name, str) and owner and name:
|
|
return owner, name, f"{owner}/{name}"
|
|
repo = metadata.get("repo")
|
|
if isinstance(repo, dict):
|
|
o = repo.get("owner")
|
|
n = repo.get("name")
|
|
if isinstance(o, str) and isinstance(n, str) and o and n:
|
|
return o, n, f"{o}/{n}"
|
|
return "", "", ""
|
|
|
|
|
|
def _run_status_to_agent_status(thread_status: str | None, run_status: str | None) -> str:
|
|
if thread_status == "busy" or run_status in {"pending", "running"}:
|
|
return "running"
|
|
if run_status in {"error", "failed", "timeout", "interrupted"}:
|
|
return "error"
|
|
if run_status == "success":
|
|
return "finished"
|
|
return "idle"
|
|
|
|
|
|
def _thread_summary(
|
|
thread: dict[str, Any], *, messages: list[dict[str, Any]] | None = None
|
|
) -> dict[str, Any]:
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
owner, name, full_name = _metadata_repo(metadata)
|
|
created_at = metadata.get("created_at_ms")
|
|
updated_at = metadata.get("updated_at_ms")
|
|
title = metadata.get("title") if isinstance(metadata.get("title"), str) else "Untitled agent"
|
|
model = metadata.get("model") if isinstance(metadata.get("model"), str) else "Default"
|
|
effort = metadata.get("effort") if isinstance(metadata.get("effort"), str) else None
|
|
thread_status = thread.get("status") if isinstance(thread.get("status"), str) else "idle"
|
|
latest_run_status = metadata.get("latest_run_status")
|
|
status = _run_status_to_agent_status(
|
|
thread_status,
|
|
latest_run_status if isinstance(latest_run_status, str) else None,
|
|
)
|
|
|
|
pr_number = metadata.get("pr_number")
|
|
pr_url = metadata.get("pr_url")
|
|
pr_title = metadata.get("pr_title")
|
|
pr_state = metadata.get("pr_state")
|
|
|
|
summary: dict[str, Any] = {
|
|
"id": thread.get("thread_id") or thread.get("id"),
|
|
"title": title,
|
|
"repo": name,
|
|
"repoFullName": full_name,
|
|
"branch": metadata.get("branch_name") or metadata.get("base_branch") or "main",
|
|
"model": model,
|
|
"effort": effort,
|
|
"source": _thread_source(metadata),
|
|
"status": status,
|
|
"createdAt": int(created_at) if isinstance(created_at, (int, float)) else _now_ms(),
|
|
"updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(),
|
|
}
|
|
if isinstance(pr_number, int) and isinstance(pr_url, str):
|
|
summary["pr"] = {
|
|
"number": pr_number,
|
|
"title": pr_title if isinstance(pr_title, str) else title,
|
|
"state": pr_state if isinstance(pr_state, str) else "open",
|
|
"headRef": metadata.get("branch_name") or "",
|
|
"baseRef": metadata.get("base_branch") or "main",
|
|
"url": pr_url,
|
|
}
|
|
if messages is not None:
|
|
summary["messages"] = messages
|
|
else:
|
|
summary["messages"] = []
|
|
return summary
|
|
|
|
|
|
async def _latest_run_status(thread_id: str) -> str | None:
|
|
runs = await langgraph_client().runs.list(thread_id, limit=1)
|
|
if not runs:
|
|
return None
|
|
run = runs[0]
|
|
raw = run.get("status") if isinstance(run, dict) else getattr(run, "status", None)
|
|
return raw.lower() if isinstance(raw, str) else None
|
|
|
|
|
|
async def list_dashboard_threads(
|
|
login: str, *, email: str | None = None, limit: int = 50
|
|
) -> list[dict[str, Any]]:
|
|
client = langgraph_client()
|
|
searches: list[dict[str, Any]] = [{"github_login": login}]
|
|
if email and email.strip():
|
|
searches.append({"triggering_user_email": email.strip().lower()})
|
|
|
|
seen: dict[str, dict[str, Any]] = {}
|
|
for metadata_filter in searches:
|
|
threads = await client.threads.search(
|
|
metadata=metadata_filter,
|
|
limit=limit,
|
|
sort_by="updated_at",
|
|
sort_order="desc",
|
|
)
|
|
for thread in threads or []:
|
|
if not isinstance(thread, dict):
|
|
continue
|
|
meta = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
if not _user_owns_thread(meta, login, email):
|
|
continue
|
|
thread_id = thread.get("thread_id") or thread.get("id")
|
|
if isinstance(thread_id, str) and thread_id not in seen:
|
|
seen[thread_id] = thread
|
|
|
|
summaries = [_thread_summary(thread) for thread in seen.values()]
|
|
summaries.sort(key=lambda item: item.get("updatedAt", 0), reverse=True)
|
|
return summaries[:limit]
|
|
|
|
|
|
async def get_dashboard_thread(
|
|
thread_id: str, login: str, *, email: str | None = None
|
|
) -> dict[str, Any]:
|
|
client = langgraph_client()
|
|
try:
|
|
thread = await client.threads.get(thread_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("Thread lookup failed for %s", thread_id, exc_info=True)
|
|
raise HTTPException(404, "thread not found") from exc
|
|
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
_assert_thread_owner(metadata, login, email)
|
|
|
|
messages: list[dict[str, Any]] = []
|
|
try:
|
|
state = await client.threads.get_state(thread_id)
|
|
except InternalServerError:
|
|
logger.warning(
|
|
"Thread state unavailable for %s (checkpoint replay failed); returning metadata only",
|
|
thread_id,
|
|
)
|
|
else:
|
|
values = state.get("values") if isinstance(state, dict) else {}
|
|
raw_messages = values.get("messages") if isinstance(values, dict) else []
|
|
messages = state_messages_to_ui(raw_messages if isinstance(raw_messages, list) else [])
|
|
|
|
latest_run_status = await _latest_run_status(thread_id)
|
|
if latest_run_status and latest_run_status != metadata.get("latest_run_status"):
|
|
metadata = {**metadata, "latest_run_status": latest_run_status}
|
|
thread = {**thread, "metadata": metadata}
|
|
|
|
return _thread_summary(thread, messages=messages)
|
|
|
|
|
|
def _resolve_repo_config(repo: str | None) -> dict[str, str]:
|
|
"""Resolve the run's repo from the request, or ``{}`` when none is given.
|
|
|
|
A repo is optional: the agent identifies and clones the target repo from the
|
|
task itself. The dashboard pre-fills the user's default repo on the client,
|
|
so the request value is authoritative here — an empty value means an
|
|
intentionally repo-less run, not "fall back to the saved default".
|
|
"""
|
|
return _parse_repo(repo) or {}
|
|
|
|
|
|
async def _start_agent_run(
|
|
thread_id: str,
|
|
*,
|
|
login: str,
|
|
repo_config: dict[str, str],
|
|
prompt: str,
|
|
title: str | None = None,
|
|
model_id: str | None = None,
|
|
effort: str | None = None,
|
|
) -> dict[str, Any]:
|
|
profile = await get_profile(login) or {}
|
|
now_ms = _now_ms()
|
|
chosen_model, chosen_effort = _normalize_model_choice(model_id, effort)
|
|
metadata_model = chosen_model or profile.get("default_model") or "Default"
|
|
metadata_effort = chosen_effort or profile.get("reasoning_effort")
|
|
has_repo = bool(repo_config.get("owner") and repo_config.get("name"))
|
|
metadata: dict[str, Any] = {
|
|
"source": _DASHBOARD_SOURCE,
|
|
"github_login": login,
|
|
"title": title or prompt[:80] or "New agent",
|
|
"base_branch": profile.get("base_branch") or "main",
|
|
"branch_prefix": profile.get("branch_prefix"),
|
|
"model": metadata_model,
|
|
"effort": metadata_effort,
|
|
"created_at_ms": now_ms,
|
|
"updated_at_ms": now_ms,
|
|
}
|
|
if has_repo:
|
|
metadata["repo_owner"] = repo_config["owner"]
|
|
metadata["repo_name"] = repo_config["name"]
|
|
|
|
client = langgraph_client()
|
|
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
|
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
|
await _ensure_dashboard_github_token(login)
|
|
|
|
configurable: dict[str, Any] = {
|
|
"thread_id": thread_id,
|
|
"source": _DASHBOARD_SOURCE,
|
|
"github_login": login,
|
|
"user_email": await _resolve_run_email(login, profile),
|
|
}
|
|
if has_repo:
|
|
configurable["repo"] = repo_config
|
|
if chosen_model and chosen_effort:
|
|
configurable["agent_model_id"] = chosen_model
|
|
configurable["agent_effort"] = chosen_effort
|
|
|
|
run = await client.runs.create(
|
|
thread_id,
|
|
_ASSISTANT_ID,
|
|
input={"messages": [{"role": "user", "content": prompt}]},
|
|
config={"configurable": configurable, "metadata": _agent_version_metadata()},
|
|
if_not_exists="create",
|
|
stream_mode=list(_DASHBOARD_STREAM_MODES),
|
|
stream_resumable=True,
|
|
)
|
|
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
|
|
await client.threads.update(
|
|
thread_id=thread_id,
|
|
metadata={"latest_run_id": run_id, "latest_run_status": "pending", "updated_at_ms": now_ms},
|
|
)
|
|
thread = await client.threads.get(thread_id)
|
|
return _thread_summary(
|
|
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
|
)
|
|
|
|
|
|
async def create_dashboard_thread(login: str, body: ThreadCreateBody) -> dict[str, Any]:
|
|
repo_config = _resolve_repo_config(body.repo)
|
|
thread_id = str(uuid.uuid4())
|
|
return await _start_agent_run(
|
|
thread_id,
|
|
login=login,
|
|
repo_config=repo_config,
|
|
prompt=body.prompt.strip(),
|
|
model_id=body.model_id,
|
|
effort=body.effort,
|
|
)
|
|
|
|
|
|
async def send_dashboard_message(
|
|
thread_id: str, login: str, body: ThreadMessageBody, *, email: str | None = None
|
|
) -> dict[str, Any]:
|
|
client = langgraph_client()
|
|
try:
|
|
thread = await client.threads.get(thread_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(404, "thread not found") from exc
|
|
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
_assert_thread_owner(metadata, login, email)
|
|
owner, name, _ = _metadata_repo(metadata)
|
|
|
|
prompt = body.content.strip()
|
|
now_ms = _now_ms()
|
|
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
|
|
metadata_update: dict[str, Any] = {"source": _DASHBOARD_SOURCE, "updated_at_ms": now_ms}
|
|
if chosen_model and chosen_effort:
|
|
metadata_update["model"] = chosen_model
|
|
metadata_update["effort"] = chosen_effort
|
|
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
|
|
|
|
if await is_thread_active(thread_id):
|
|
queued = await queue_message_for_thread(
|
|
thread_id,
|
|
{"text": prompt, "source": _DASHBOARD_SOURCE},
|
|
)
|
|
if not queued:
|
|
raise HTTPException(502, "failed to queue follow-up message")
|
|
thread = await client.threads.get(thread_id)
|
|
return _thread_summary(
|
|
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
|
)
|
|
|
|
await _ensure_dashboard_github_token(login)
|
|
profile = await get_profile(login) or {}
|
|
configurable: dict[str, Any] = {
|
|
"thread_id": thread_id,
|
|
"source": _DASHBOARD_SOURCE,
|
|
"github_login": login,
|
|
"user_email": await _resolve_run_email(login, profile),
|
|
}
|
|
if owner and name:
|
|
configurable["repo"] = {"owner": owner, "name": name}
|
|
if chosen_model and chosen_effort:
|
|
configurable["agent_model_id"] = chosen_model
|
|
configurable["agent_effort"] = chosen_effort
|
|
run = await client.runs.create(
|
|
thread_id,
|
|
_ASSISTANT_ID,
|
|
input={"messages": [{"role": "user", "content": prompt}]},
|
|
config={"configurable": configurable, "metadata": _agent_version_metadata()},
|
|
stream_mode=list(_DASHBOARD_STREAM_MODES),
|
|
stream_resumable=True,
|
|
)
|
|
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
|
|
await client.threads.update(
|
|
thread_id=thread_id,
|
|
metadata={"latest_run_id": run_id, "latest_run_status": "pending", "updated_at_ms": now_ms},
|
|
)
|
|
thread = await client.threads.get(thread_id)
|
|
return _thread_summary(
|
|
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
|
)
|
|
|
|
|
|
async def cancel_dashboard_thread(
|
|
thread_id: str, login: str, *, email: str | None = None
|
|
) -> dict[str, Any]:
|
|
client = langgraph_client()
|
|
try:
|
|
thread = await client.threads.get(thread_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(404, "thread not found") from exc
|
|
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
_assert_thread_owner(metadata, login, email)
|
|
|
|
run_id = metadata.get("latest_run_id")
|
|
if isinstance(run_id, str) and run_id:
|
|
try:
|
|
await client.runs.cancel(thread_id, run_id, wait=False)
|
|
except Exception:
|
|
logger.debug("Could not cancel run %s for thread %s", run_id, thread_id, exc_info=True)
|
|
|
|
await client.threads.update(
|
|
thread_id=thread_id,
|
|
metadata={"latest_run_status": "interrupted", "updated_at_ms": _now_ms()},
|
|
)
|
|
thread = await client.threads.get(thread_id)
|
|
return _thread_summary(
|
|
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
|
)
|
|
|
|
|
|
async def delete_dashboard_thread(thread_id: str, login: str, *, email: str | None = None) -> None:
|
|
client = langgraph_client()
|
|
try:
|
|
thread = await client.threads.get(thread_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(404, "thread not found") from exc
|
|
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
_assert_thread_owner(metadata, login, email)
|
|
|
|
run_id = metadata.get("latest_run_id")
|
|
if isinstance(run_id, str) and run_id:
|
|
try:
|
|
await client.runs.cancel(thread_id, run_id, wait=False)
|
|
except Exception:
|
|
logger.debug("Could not cancel run %s for thread %s", run_id, thread_id, exc_info=True)
|
|
|
|
await client.threads.delete(thread_id)
|
|
|
|
|
|
async def stream_dashboard_thread(
|
|
thread_id: str, login: str, *, email: str | None = None, last_event_id: str | None = None
|
|
) -> AsyncIterator[str]:
|
|
try:
|
|
thread = await langgraph_client().threads.get(thread_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(404, "thread not found") from exc
|
|
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
_assert_thread_owner(metadata, login, email)
|
|
|
|
stream = await langgraph_client().threads.join_stream(
|
|
thread_id,
|
|
last_event_id=last_event_id,
|
|
)
|
|
async for part in stream:
|
|
event = getattr(part, "event", None) or (
|
|
part.get("event") if isinstance(part, dict) else None
|
|
)
|
|
data = getattr(part, "data", None) if not isinstance(part, dict) else part.get("data")
|
|
event_id = getattr(part, "id", None) if not isinstance(part, dict) else part.get("id")
|
|
payload: dict[str, Any] = {"event": event, "data": data}
|
|
if event_id is not None:
|
|
payload["id"] = event_id
|
|
chunk = f"data: {json.dumps(payload, default=str)}\n\n"
|
|
if event_id is not None:
|
|
chunk = f"id: {event_id}\n{chunk}"
|
|
yield chunk
|