Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/hackbot-prod-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ on:
- agents/test-repair
- agents/test-repair-fix
- services/hackbot-api
- services/hackbot-mcp
- services/hackbot-ui
action:
description: "promote: what stage runs; rollback: prod's previous deployment"
Expand Down
2 changes: 2 additions & 0 deletions .taskcluster.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ tasks:
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=app --cov-append tests/ &&
cd ../hackbot-pulse-listener &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=app --cov-append tests/ &&
cd ../hackbot-mcp &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=hackbot_mcp --cov-append tests/ &&
cd ../mcp &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --with pytest-mock==3.15.1 pytest --cov=bugbug_mcp --cov-append tests/ &&
cd ../.. &&
Expand Down
10 changes: 9 additions & 1 deletion libs/hackbot-client/hackbot_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,28 @@
HackbotInvalidResponseError,
)
from hackbot_client.models import (
AgentDescriptor,
ApplyActionsResponse,
ArtifactRef,
RunAction,
RunDoc,
RunRef,
RunStatus,
RunSummary,
TriggeredRun,
)

__all__ = [
"AgentDescriptor",
"ApplyActionsResponse",
"ArtifactRef",
"HackbotAPIError",
"HackbotClient",
"HackbotInvalidResponseError",
"RunAction",
"ApplyActionsResponse",
"RunDoc",
"RunRef",
"RunStatus",
"RunSummary",
"TriggeredRun",
]
258 changes: 204 additions & 54 deletions libs/hackbot-client/hackbot_client/client.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,62 @@
"""Small async client for the public Hackbot API."""

import asyncio
import json
from collections.abc import Mapping
from typing import Any
from urllib.parse import quote
from uuid import UUID

import httpx
from google.auth import compute_engine, credentials
from google.auth.transport import requests as google_requests
from google.oauth2 import service_account
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError

from hackbot_client.models import ApplyActionsResponse, TriggeredRun
from hackbot_client.models import (
AgentDescriptor,
ApplyActionsResponse,
RunDoc,
RunStatus,
TriggeredRun,
)


class HackbotAPIError(Exception):
"""A request to the Hackbot API failed (or answered with something unusable)."""
"""A request to the Hackbot API failed (or answered with something unusable).

`status_code` is the API's HTTP status when it answered with an error, and
`detail` the reason it gave, if any; both are None when it never answered.
"""

def __init__(
self,
message: str,
*,
status_code: int | None = None,
detail: str | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.detail = detail


class HackbotInvalidResponseError(HackbotAPIError):
"""The Hackbot API answered successfully, but with a body that is unusable."""


def _error_detail(response: httpx.Response) -> str | None:
"""The reason an error response gives, from FastAPI's `{"detail": ...}`."""
try:
body = response.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if detail is None:
return None
return detail if isinstance(detail, str) else json.dumps(detail)


class HackbotClient:
"""Call the public Hackbot API with either API key or service account auth."""

Expand Down Expand Up @@ -83,50 +118,183 @@ async def _get_headers(self) -> dict[str, str]:
await asyncio.to_thread(self._refresh_credentials)
return {"Authorization": f"Bearer {self._credentials.token}"}

async def trigger_run(
async def _send(
self,
agent_name: str,
inputs: Mapping[str, Any],
method: str,
path: str,
*,
on_behalf_of: str | None = None,
dedupe_key: str | None = None,
) -> TriggeredRun:
"""Create an agent run and return the API's typed run reference.

`dedupe_key` keys the work the run does, and a key belongs to one run
for good: repeated triggers carrying it are no-ops, answered with the
same run reference and `is_new=False`.
"""
params: Mapping[str, Any] | None = None,
json: Any = None,
) -> httpx.Response:
"""Send one request, raising `HackbotAPIError` unless it succeeded."""
headers = await self._get_headers()
if on_behalf_of is not None:
headers["X-On-Behalf-Of"] = on_behalf_of

params = {} if dedupe_key is None else {"dedupe_key": dedupe_key}
kwargs: dict[str, Any] = {"headers": headers}
if params is not None:
kwargs["params"] = dict(params)
if json is not None:
kwargs["json"] = json

try:
response = await self._http.post(
f"{self._base_url}/agents/{agent_name}/runs",
json=dict(inputs),
headers=headers,
params=params,
)
if method == "GET":
response = await self._http.get(f"{self._base_url}{path}", **kwargs)
else:
response = await self._http.post(f"{self._base_url}{path}", **kwargs)
except httpx.HTTPError as exc:
raise HackbotAPIError(f"Hackbot API request failed: {exc}") from exc

if not response.is_success:
detail = _error_detail(response)
raise HackbotAPIError(
f"Hackbot API returned HTTP {response.status_code} "
f"({response.reason_phrase}) for {response.request.url}"
+ (f": {detail}" if detail else ""),
status_code=response.status_code,
detail=detail,
)
return response

@staticmethod
def _body(response: httpx.Response, what: str) -> Any:
try:
body = response.json()
return response.json()
except ValueError as exc:
raise HackbotInvalidResponseError(
f"Hackbot API returned an invalid run (HTTP {response.status_code}): "
"the body is not JSON"
f"Hackbot API returned an invalid {what} "
f"(HTTP {response.status_code}): the body is not JSON"
) from exc

@staticmethod
def _validate[M: BaseModel](
model: type[M], body: Any, response: httpx.Response, what: str
) -> M:
try:
return model.model_validate(body)
except ValidationError as exc:
raise HackbotInvalidResponseError(
f"Hackbot API returned an invalid {what} (HTTP {response.status_code})"
) from exc

async def list_agents(
self, *, on_behalf_of: str | None = None
) -> list[AgentDescriptor]:
"""Every agent the API can run, with its description and input schema."""
response = await self._send("GET", "/agents", on_behalf_of=on_behalf_of)
body = self._body(response, "agent list")
if not isinstance(body, list):
raise HackbotInvalidResponseError(
f"Hackbot API returned an invalid agent list "
f"(HTTP {response.status_code}): the body is not a list"
)
return [
self._validate(AgentDescriptor, item, response, "agent list")
for item in body
]

async def get_run(
self, run_id: str | UUID, *, on_behalf_of: str | None = None
) -> RunDoc:
response = await self._send("GET", f"/runs/{run_id}", on_behalf_of=on_behalf_of)
return self._validate(RunDoc, self._body(response, "run"), response, "run")

async def list_runs(
self,
*,
agent: str | None = None,
status: RunStatus | str | None = None,
requested_by: str | None = None,
dedupe_key: str | None = None,
limit: int | None = None,
offset: int | None = None,
on_behalf_of: str | None = None,
) -> list[RunDoc]:
"""Runs matching every filter given, newest first."""
params = {
name: value
for name, value in {
"agent": agent,
"status": RunStatus(status).value if status is not None else None,
"requested_by": requested_by,
"dedupe_key": dedupe_key,
"limit": limit,
"offset": offset,
}.items()
if value is not None
}
response = await self._send(
"GET", "/runs", on_behalf_of=on_behalf_of, params=params
)
body = self._body(response, "run list")
if not isinstance(body, list):
raise HackbotInvalidResponseError(
f"Hackbot API returned an invalid run list "
f"(HTTP {response.status_code}): the body is not a list"
)
return [self._validate(RunDoc, item, response, "run list") for item in body]

async def list_actions(
self, run_id: str | UUID, *, on_behalf_of: str | None = None
) -> ApplyActionsResponse:
"""A run's recorded actions and their apply state.

Empty until the API records them, which it does once a succeeded run is
processed (or on the first apply); until then, `summary.actions` on the
run lists what the agent proposed.
"""
response = await self._send(
"GET", f"/runs/{run_id}/actions", on_behalf_of=on_behalf_of
)
return self._validate(
ApplyActionsResponse,
self._body(response, "action list"),
response,
"action list",
)

async def get_artifact_url(
self, run_id: str | UUID, artifact: str, *, on_behalf_of: str | None = None
) -> str:
"""A short-lived signed URL to download one of a run's artifacts."""
response = await self._send(
"GET",
f"/runs/{run_id}/artifacts/{quote(artifact.lstrip('/'))}",
on_behalf_of=on_behalf_of,
)
body = self._body(response, "artifact URL")
if not isinstance(body, dict) or not isinstance(body.get("url"), str):
raise HackbotInvalidResponseError(
f"Hackbot API returned an invalid artifact URL "
f"(HTTP {response.status_code})"
)
return body["url"]

async def trigger_run(
self,
agent_name: str,
inputs: Mapping[str, Any],
*,
on_behalf_of: str | None = None,
dedupe_key: str | None = None,
) -> TriggeredRun:
"""Create an agent run and return the API's typed run reference.

`dedupe_key` keys the work the run does, and a key belongs to one run
for good: repeated triggers carrying it are no-ops, answered with the
same run reference and `is_new=False`.
"""
params = {} if dedupe_key is None else {"dedupe_key": dedupe_key}
response = await self._send(
"POST",
f"/agents/{agent_name}/runs",
on_behalf_of=on_behalf_of,
params=params,
json=dict(inputs),
)
body = self._body(response, "run")

if not isinstance(body, dict):
raise HackbotInvalidResponseError(
f"Hackbot API returned an invalid run (HTTP {response.status_code}): "
Expand All @@ -144,38 +312,20 @@ async def trigger_run(
f"Hackbot API returned an invalid run (HTTP {response.status_code})"
) from exc

async def apply_actions(self, run_id: str | UUID) -> ApplyActionsResponse:
async def apply_actions(
self, run_id: str | UUID, *, on_behalf_of: str | None = None
) -> ApplyActionsResponse:
"""Apply every one of a run's actions that has not landed yet.

Idempotent, because the API skips rows already marked applied: calling
this on a run whose actions all landed is a no-op.
"""
try:
response = await self._http.post(
f"{self._base_url}/runs/{run_id}/actions/apply",
headers=await self._get_headers(),
)
except httpx.HTTPError as exc:
raise HackbotAPIError(f"Hackbot API request failed: {exc}") from exc

if not response.is_success:
raise HackbotAPIError(
f"Hackbot API returned HTTP {response.status_code} "
f"({response.reason_phrase}) for {response.request.url}"
)

try:
body = response.json()
except ValueError as exc:
raise HackbotInvalidResponseError(
"Hackbot API returned an invalid action list "
f"(HTTP {response.status_code}): the body is not JSON"
) from exc

try:
return ApplyActionsResponse.model_validate(body)
except ValidationError as exc:
raise HackbotInvalidResponseError(
"Hackbot API returned an invalid action list "
f"(HTTP {response.status_code})"
) from exc
response = await self._send(
"POST", f"/runs/{run_id}/actions/apply", on_behalf_of=on_behalf_of
)
return self._validate(
ApplyActionsResponse,
self._body(response, "action list"),
response,
"action list",
)
Loading