open-swe/agent/dashboard/thread_api.py
Johannes du Plessis ec41f138fa
fix: update sidebar thread activity indicators (#1441)
* fix: update sidebar thread activity indicators

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

* fix: improve sidebar thread organization

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

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-08 10:38:31 -07:00

733 lines
28 KiB
Python

"""Dashboard thread list/detail/run/stream endpoints backed by LangGraph."""
from __future__ import annotations
import base64
import binascii
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 langchain_core.messages.content import create_image_block
from langgraph_sdk.errors import InternalServerError
from pydantic import BaseModel, ConfigDict, Field
from ..utils.thread_ops import is_thread_active, langgraph_client, queue_message_for_thread
from .agent_overrides import normalize_profile_overrides
from .message_adapter import state_messages_to_ui
from .options import SUPPORTED_MODEL_IDS, model_supports_effort, model_supports_images
from .profiles import get_profile, get_valid_access_token
from .team_settings import get_team_default_model
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")
_SUPPORTED_IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
_MAX_DASHBOARD_IMAGES = 5
_MAX_DASHBOARD_IMAGE_BYTES = 10 * 1024 * 1024
# 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 DashboardImageBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
kind: str | None = None
base64: str = Field(min_length=1)
mime_type: str = Field(alias="mimeType", min_length=1)
file_name: str | None = Field(default=None, alias="fileName")
class ThreadCreateBody(BaseModel):
prompt: str = Field(default="", max_length=20_000)
images: list[DashboardImageBody] = Field(default_factory=list)
repo: str | None = None
repo_explicitly_none: bool = False
model_id: str | None = None
effort: str | None = None
class ThreadMessageBody(BaseModel):
content: str = Field(default="", max_length=20_000)
images: list[DashboardImageBody] = Field(default_factory=list)
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
async def _resolve_agent_model_choice(
profile: dict[str, Any],
model_id: str | None,
effort: str | None,
) -> tuple[str, str]:
resolved_model, resolved_effort = await get_team_default_model("agent")
profile_model, profile_effort = normalize_profile_overrides(profile)
if profile_model and profile_effort:
resolved_model, resolved_effort = profile_model, profile_effort
chosen_model, chosen_effort = _normalize_model_choice(model_id, effort)
if chosen_model and chosen_effort:
resolved_model, resolved_effort = chosen_model, chosen_effort
return resolved_model, resolved_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}
def _decode_dashboard_image(image: DashboardImageBody) -> bytes:
if image.mime_type not in _SUPPORTED_IMAGE_MIME_TYPES:
raise HTTPException(422, f"unsupported image type: {image.mime_type}")
try:
data = base64.b64decode(image.base64, validate=True)
except binascii.Error as exc:
raise HTTPException(422, "invalid image data") from exc
if len(data) > _MAX_DASHBOARD_IMAGE_BYTES:
raise HTTPException(422, "image exceeds 10MB limit")
return data
def _image_blocks(
images: list[DashboardImageBody], *, model_id: str | None
) -> list[dict[str, Any]]:
if len(images) > _MAX_DASHBOARD_IMAGES:
raise HTTPException(422, f"at most {_MAX_DASHBOARD_IMAGES} images are supported")
if images and (not model_id or not model_supports_images(model_id)):
model_label = model_id or "the current model"
raise HTTPException(422, f"model {model_label} does not support image input")
return [
create_image_block(
base64=base64.b64encode(_decode_dashboard_image(image)).decode("ascii"),
mime_type=image.mime_type,
)
for image in images
]
def _user_message_content(
prompt: str, images: list[DashboardImageBody], *, model_id: str | None = None
) -> str | list[dict[str, Any]]:
text = prompt.strip()
if not text and not images:
raise HTTPException(422, "prompt or image required")
if not images:
return text
return [
*_image_blocks(images, model_id=model_id),
*([{"type": "text", "text": text}] if text else []),
]
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 _metadata_model_id(metadata: dict[str, Any]) -> str | None:
for key in ("resolved_model", "model"):
model = metadata.get(key)
if isinstance(model, str) and model in SUPPORTED_MODEL_IDS:
return model
return None
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 {"interrupted", "cancelled"}:
return "interrupted"
if run_status in {"error", "failed", "timeout"}:
return "error"
if run_status == "success":
return "finished"
return "idle"
def _thread_run_id(metadata: dict[str, Any], latest_run_id: str | None) -> str | None:
if latest_run_id:
return latest_run_id
run_id = metadata.get("latest_run_id")
return run_id if isinstance(run_id, str) and run_id else None
def _is_thread_viewed(metadata: dict[str, Any], latest_run_id: str | None) -> bool:
viewed_at = metadata.get("last_viewed_at_ms")
viewed_run_id = metadata.get("last_viewed_run_id")
run_id = _thread_run_id(metadata, latest_run_id)
if run_id:
return viewed_run_id == run_id
return isinstance(viewed_at, (int, float))
def _thread_summary(
thread: dict[str, Any],
*,
messages: list[dict[str, Any]] | None = None,
latest_run_status: str | None = None,
latest_run_id: str | 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"
metadata_run_status = metadata.get("latest_run_status")
run_status = latest_run_status or (
metadata_run_status if isinstance(metadata_run_status, str) else None
)
status = _run_status_to_agent_status(thread_status, run_status)
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,
"viewed": _is_thread_viewed(metadata, latest_run_id),
"viewedAt": (
int(metadata["last_viewed_at_ms"])
if isinstance(metadata.get("last_viewed_at_ms"), (int, float))
else None
),
"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_info(client: Any, thread_id: str) -> tuple[str | None, str | None]:
try:
runs = await client.runs.list(thread_id, limit=1)
except Exception: # noqa: BLE001
logger.debug("Could not fetch latest run for thread %s", thread_id, exc_info=True)
return None, None
if not runs:
return None, None
run = runs[0]
raw_status = run.get("status") if isinstance(run, dict) else getattr(run, "status", None)
raw_id = (
(run.get("run_id") or run.get("id"))
if isinstance(run, dict)
else (getattr(run, "run_id", None) or getattr(run, "id", None))
)
status = raw_status.lower() if isinstance(raw_status, str) else None
run_id = raw_id if isinstance(raw_id, str) and raw_id else None
return status, run_id
async def _latest_run_status(thread_id: str) -> str | None:
status, _ = await _latest_run_info(langgraph_client(), thread_id)
return status
async def _refresh_latest_run_metadata(
client: Any, thread: dict[str, Any]
) -> tuple[dict[str, Any], str | None, str | None]:
thread_id = thread.get("thread_id") or thread.get("id")
if not isinstance(thread_id, str) or not thread_id:
return thread, None, None
latest_run_status, latest_run_id = await _latest_run_info(client, thread_id)
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
metadata_update: dict[str, Any] = {}
if latest_run_status and latest_run_status != metadata.get("latest_run_status"):
metadata_update["latest_run_status"] = latest_run_status
if latest_run_id and latest_run_id != metadata.get("latest_run_id"):
metadata_update["latest_run_id"] = latest_run_id
if metadata_update:
try:
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
except Exception: # noqa: BLE001
logger.debug("Could not persist latest run metadata for %s", thread_id, exc_info=True)
else:
thread = {**thread, "metadata": {**metadata, **metadata_update}}
return thread, latest_run_status, latest_run_id
async def list_dashboard_threads(
login: str, *, email: str | None = None, limit: int = 50, include_all: bool = False
) -> list[dict[str, Any]]:
client = langgraph_client()
searches: list[dict[str, Any]] = [{}] if include_all else [{"github_login": login}]
if not include_all and 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 include_all and 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: list[dict[str, Any]] = []
for thread in seen.values():
refreshed, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(
client, thread
)
summaries.append(
_thread_summary(
refreshed,
latest_run_status=latest_run_status,
latest_run_id=latest_run_id,
)
)
summaries.sort(key=lambda item: item.get("updatedAt", 0), reverse=True)
return summaries[:limit]
async def _mark_thread_viewed(
client: Any,
thread_id: str,
metadata: dict[str, Any],
*,
latest_run_id: str | None,
) -> dict[str, Any]:
now_ms = _now_ms()
metadata_update: dict[str, Any] = {"last_viewed_at_ms": now_ms}
run_id = _thread_run_id(metadata, latest_run_id)
if run_id:
metadata_update["last_viewed_run_id"] = run_id
try:
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
except Exception: # noqa: BLE001
logger.debug("Could not mark thread %s viewed", thread_id, exc_info=True)
return metadata
return {**metadata, **metadata_update}
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 [])
thread, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(client, thread)
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
status = _run_status_to_agent_status(
thread.get("status") if isinstance(thread.get("status"), str) else "idle",
latest_run_status
or (
metadata.get("latest_run_status")
if isinstance(metadata.get("latest_run_status"), str)
else None
),
)
if status != "running":
metadata = await _mark_thread_viewed(
client,
thread_id,
metadata,
latest_run_id=latest_run_id,
)
thread = {**thread, "metadata": metadata}
return _thread_summary(
thread,
messages=messages,
latest_run_status=latest_run_status,
latest_run_id=latest_run_id,
)
def _resolve_repo_config(repo: str | None) -> dict[str, str]:
"""Resolve the run's repo from the request, or ``{}`` when none is given."""
return _parse_repo(repo) or {}
async def _start_agent_run(
thread_id: str,
*,
login: str,
repo_config: dict[str, str],
repo_explicitly_none: bool = False,
prompt: str,
images: list[DashboardImageBody] | None = None,
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()
prompt = prompt.strip()
resolved_model, resolved_effort = await _resolve_agent_model_choice(profile, model_id, effort)
content = _user_message_content(prompt, images or [], model_id=resolved_model)
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,
"resolved_model": resolved_model,
"resolved_effort": resolved_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"]
elif repo_explicitly_none:
metadata["repo_explicitly_none"] = True
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
elif repo_explicitly_none:
configurable["repo_explicitly_none"] = True
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": content}]},
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,
repo_explicitly_none=body.repo_explicitly_none,
prompt=body.prompt,
images=body.images,
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
if await is_thread_active(thread_id):
active_model = _metadata_model_id(metadata) if body.images else None
content = _user_message_content(prompt, body.images, model_id=active_model)
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
queue_payload: dict[str, Any] = {"text": prompt, "source": _DASHBOARD_SOURCE}
if isinstance(content, list):
queue_payload["images"] = [
block
for block in content
if isinstance(block, dict) and block.get("type") != "text"
]
queued = await queue_message_for_thread(
thread_id,
queue_payload,
)
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}
)
profile = await get_profile(login) or {}
resolved_model, resolved_effort = await _resolve_agent_model_choice(
profile, body.model_id, body.effort
)
metadata_update["resolved_model"] = resolved_model
metadata_update["resolved_effort"] = resolved_effort
content = _user_message_content(prompt, body.images, model_id=resolved_model)
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
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 owner and name:
configurable["repo"] = {"owner": owner, "name": name}
elif metadata.get("repo_explicitly_none") is True:
configurable["repo_explicitly_none"] = True
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": content}]},
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, action="interrupt")
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:
await langgraph_client().threads.get(thread_id)
except Exception as exc: # noqa: BLE001
raise HTTPException(404, "thread not found") from exc
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