open-swe/agent/dashboard/thread_api.py
Johannes du Plessis 6895ddcedc
feat: add scheduled web agents (#1422)
* feat: add scheduled web agents

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>

* fix: secure scheduled agent repositories

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>

* feat: rebuild scheduled agents as Automations tab

Scrap the inline ScheduledAgentsPanel and replace it with a dedicated
Automations tab: sidebar nav entry, list view with stat cards + empty
state, and a full editor (name, Active toggle, repo, scheduled trigger
picker, agent instructions + model).

* fix: clear collapsed-sidebar button on mobile in Automations

* fix: allow clearing automation repo on update

---------

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
2026-06-05 02:20:24 +00:00

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", "schedule")
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