mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 17:23:15 +00:00
* feat: add user-scoped Currents.dev API key for e2e test investigation
Allow each user to configure their own Currents.dev API key on the
Profile Settings page. The key is encrypted at rest in a per-user
LangGraph Store namespace and feeds server-side read-only tools that
query the Currents REST API (runs, instances, projects, test results)
so agent runs can inspect e2e test failures including screenshots and
DOM snapshots.
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
* fix: add pagination cursors to currents_list_project_runs
Address review feedback: forward starting_after/ending_before cursor
parameters to /projects/{projectId}/runs so the agent can paginate
beyond the first 50 results.
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
178 lines
6.7 KiB
Python
178 lines
6.7 KiB
Python
"""Server-side, read-only Currents.dev tools for e2e test investigation.
|
|
|
|
Credentials are per-user (encrypted at rest in the user-credentials Store
|
|
namespace). The tools run in the LangGraph server process and call the
|
|
Currents REST API directly — the sandbox never holds a Currents key.
|
|
|
|
The surface is intentionally read-only: fetch runs, instances, test results,
|
|
and list projects so the agent can dig into e2e test failures including
|
|
screenshots and DOM snapshots.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from langchain_core.tools import BaseTool, StructuredTool
|
|
|
|
from ..dashboard.user_credentials import CURRENTS_API_BASE, get_currents_api_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
|
|
|
|
def _headers(api_key: str) -> dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Accept": "application/json",
|
|
"Accept-Encoding": "gzip",
|
|
}
|
|
|
|
|
|
async def _get(path: str, api_key: str, **params: Any) -> dict[str, Any]:
|
|
url = f"{CURRENTS_API_BASE}{path}"
|
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
resp = await client.get(url, headers=_headers(api_key), params=params)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def _make_tools(api_key: str) -> list[BaseTool]:
|
|
async def currents_list_projects(
|
|
limit: int = 10,
|
|
starting_after: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""List Currents.dev projects for your organization.
|
|
|
|
Args:
|
|
limit: Maximum number of items to return (default 10, max 50).
|
|
starting_after: Cursor for pagination.
|
|
|
|
Returns:
|
|
Dictionary with project list, or an error message.
|
|
"""
|
|
try:
|
|
params: dict[str, Any] = {"limit": max(1, min(limit, 50))}
|
|
if starting_after:
|
|
params["starting_after"] = starting_after
|
|
return await _get("/projects", api_key, **params)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("currents_list_projects failed", exc_info=True)
|
|
return {"success": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
async def currents_get_run(run_id: str) -> dict[str, Any]:
|
|
"""Get a single Currents.dev test run by ID with full details.
|
|
|
|
Use this to inspect a specific e2e test run including specs,
|
|
screenshots, video URLs, and test stats.
|
|
|
|
Args:
|
|
run_id: The Currents run ID (e.g. "run_abc123").
|
|
|
|
Returns:
|
|
Dictionary with the run details, or an error message.
|
|
"""
|
|
try:
|
|
return await _get(f"/runs/{run_id}", api_key)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("currents_get_run failed", exc_info=True)
|
|
return {"success": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
async def currents_find_run(
|
|
project_id: str,
|
|
ci_build_id: str | None = None,
|
|
branch: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Find the most recent completed Currents.dev run matching criteria.
|
|
|
|
Args:
|
|
project_id: The Currents project ID (e.g. "proj_abc123").
|
|
ci_build_id: Optional CI build ID to find an exact run.
|
|
branch: Optional branch name or prefix (append * for prefix match).
|
|
|
|
Returns:
|
|
Dictionary with the run details, or an error message.
|
|
"""
|
|
try:
|
|
params: dict[str, Any] = {"projectId": project_id}
|
|
if ci_build_id:
|
|
params["ciBuildId"] = ci_build_id
|
|
if branch:
|
|
params["branch"] = branch
|
|
return await _get("/runs/find", api_key, **params)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("currents_find_run failed", exc_info=True)
|
|
return {"success": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
async def currents_list_project_runs(
|
|
project_id: str,
|
|
limit: int = 10,
|
|
status: str | None = None,
|
|
branch: str | None = None,
|
|
starting_after: str | None = None,
|
|
ending_before: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""List runs for a Currents.dev project with optional filters.
|
|
|
|
Args:
|
|
project_id: The Currents project ID.
|
|
limit: Maximum number of runs to return (default 10, max 50).
|
|
status: Optional status filter: PASSED, FAILED, RUNNING, FAILING.
|
|
branch: Optional branch filter (append * for prefix match).
|
|
starting_after: Cursor for pagination (next page).
|
|
ending_before: Cursor for pagination (previous page).
|
|
|
|
Returns:
|
|
Dictionary with a list of runs, or an error message.
|
|
"""
|
|
try:
|
|
params: dict[str, Any] = {"limit": max(1, min(limit, 50))}
|
|
if status:
|
|
params["status"] = status
|
|
if branch:
|
|
params["branches[]"] = branch
|
|
if starting_after:
|
|
params["starting_after"] = starting_after
|
|
if ending_before:
|
|
params["ending_before"] = ending_before
|
|
return await _get(f"/projects/{project_id}/runs", api_key, **params)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("currents_list_project_runs failed", exc_info=True)
|
|
return {"success": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
async def currents_get_instance(instance_id: str) -> dict[str, Any]:
|
|
"""Get a single Currents.dev spec file execution instance by ID.
|
|
|
|
An instance represents one spec file's execution within a run,
|
|
including detailed test results, errors, and attempt history.
|
|
|
|
Args:
|
|
instance_id: The Currents instance ID (e.g. "inst_abc123").
|
|
|
|
Returns:
|
|
Dictionary with the instance details, or an error message.
|
|
"""
|
|
try:
|
|
return await _get(f"/instances/{instance_id}", api_key)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("currents_get_instance failed", exc_info=True)
|
|
return {"success": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
return [
|
|
StructuredTool.from_function(coroutine=currents_list_projects),
|
|
StructuredTool.from_function(coroutine=currents_get_run),
|
|
StructuredTool.from_function(coroutine=currents_find_run),
|
|
StructuredTool.from_function(coroutine=currents_list_project_runs),
|
|
StructuredTool.from_function(coroutine=currents_get_instance),
|
|
]
|
|
|
|
|
|
async def load_currents_tools(login: str) -> list[BaseTool]:
|
|
"""Return read-only Currents tools when the user has connected Currents."""
|
|
api_key = await get_currents_api_key(login)
|
|
if not api_key:
|
|
return []
|
|
return _make_tools(api_key)
|