open-swe/agent/integrations/currents_tools.py

179 lines
6.7 KiB
Python
Raw Permalink Normal View History

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