diff --git a/agents/regression-range/Dockerfile b/agents/regression-range/Dockerfile new file mode 100644 index 0000000000..03f24bf6aa --- /dev/null +++ b/agents/regression-range/Dockerfile @@ -0,0 +1,80 @@ +FROM python:3.12 AS builder + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV UV_PROJECT_ENVIRONMENT=/opt/venv + +WORKDIR /app + +# Install external deps without building workspace members. +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=VERSION,target=VERSION \ + uv sync --frozen --no-dev --no-install-workspace --package hackbot-agent-regression-range + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,target=/app,rw \ + uv sync --locked --no-dev --no-editable --package hackbot-agent-regression-range + +# mozregression is installed from the fork carrying PR #2197 (the --prompt mode +# is not yet in a release), so it lives outside the workspace lockfile. Pin to a +# specific commit once the PR settles; the branch tracks the PR for now. +ARG MOZREGRESSION_REF="git+https://github.com/msujaws/mozregression.git@prompt-devtools-mcp-verdict" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv pip install --python /opt/venv "mozregression @ ${MOZREGRESSION_REF}" + +FROM python:3.12 AS base + +COPY --from=builder /opt/venv /opt/venv +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PATH="/opt/venv/bin:$PATH" + +FROM base AS agent + +# mozregression's --prompt mode (mozilla/mozregression#2197) shells out to the +# Claude CLI, which launches the Firefox DevTools MCP via npx to classify each +# build. So the agent image needs Node.js/npm/npx, the DevTools MCP, the Claude +# CLI, and the shared libraries a downloaded Firefox needs to run headless. The +# Firefox builds themselves are fetched by mozregression at bisect time. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + # Node.js/npm/npx: run the Claude CLI and the Firefox DevTools MCP. + nodejs npm \ + ca-certificates \ + # Firefox runtime deps (for the builds mozregression downloads). + libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 libxtst6 libxt6 libpci3 \ + libnss3 libnspr4 libasound2t64 libpango-1.0-0 libcairo2 \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +# The Claude CLI (invoked by mozregression --prompt) and the Firefox DevTools +# MCP it drives. mozregression launches the MCP with `npx --prefer-offline +# @mozilla/firefox-devtools-mcp`, so a global install keeps the run offline and +# fast (npx resolves the globally-installed package before hitting the network). +RUN npm install -g @anthropic-ai/claude-code @mozilla/firefox-devtools-mcp + +# hackbot.toml lives at the agent root (not inside the package), so copy it into +# the working dir; the runtime discovers it there (cwd) at startup. +COPY agents/regression-range/hackbot.toml /app/hackbot.toml + +RUN useradd --create-home --shell /bin/bash agent \ + && mkdir -p /workspace \ + && chown agent:agent /workspace + +USER agent + +CMD ["python", "-m", "hackbot_agents.regression_range"] + +FROM base AS broker + +RUN useradd --create-home --shell /bin/bash broker + +USER broker + +EXPOSE 8765 + +CMD ["python", "-m", "hackbot_agents.regression_range.broker"] diff --git a/agents/regression-range/compose.yml b/agents/regression-range/compose.yml new file mode 100644 index 0000000000..9c7671c965 --- /dev/null +++ b/agents/regression-range/compose.yml @@ -0,0 +1,33 @@ +services: + regression-range-broker: + build: + context: ../.. + dockerfile: agents/regression-range/Dockerfile + target: broker + environment: + BUGZILLA_API_URL: ${BUGZILLA_API_URL} + BUGZILLA_API_KEY: ${BUGZILLA_API_KEY} + expose: + - "8765" + + regression-range-agent: + build: + context: ../.. + dockerfile: agents/regression-range/Dockerfile + target: agent + environment: + - RUN_ID + - BUG_ID=${BUG_ID:?error} + - GOOD=${GOOD:-} + - BAD=${BAD:-} + - BUGZILLA_MCP_URL=http://regression-range-broker:8765/mcp + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?error} + # No uploader locally: summary/logs/attachments are written under + # /artifacts/, bind-mounted to the host's ~/hackbot/artifacts so + # compose and direct runs land in the same user-scoped location. + - ARTIFACTS_DIR=/artifacts + volumes: + - ${HOME}/hackbot/artifacts:/artifacts + depends_on: + regression-range-broker: + condition: service_started diff --git a/agents/regression-range/hackbot.toml b/agents/regression-range/hackbot.toml new file mode 100644 index 0000000000..627572cbe3 --- /dev/null +++ b/agents/regression-range/hackbot.toml @@ -0,0 +1,5 @@ +# This agent declares no [source] and no [firefox] table: it never needs a +# Firefox source checkout or a local build. mozregression downloads the builds +# it bisects, and its --prompt mode launches them through the Firefox DevTools +# MCP. All the agent needs from the platform is Anthropic credentials (provided +# automatically) and the brokered Bugzilla MCP (wired via env). diff --git a/agents/regression-range/hackbot_agents/regression_range/__init__.py b/agents/regression-range/hackbot_agents/regression_range/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/agents/regression-range/hackbot_agents/regression_range/__main__.py b/agents/regression-range/hackbot_agents/regression_range/__main__.py new file mode 100644 index 0000000000..bd730b25ac --- /dev/null +++ b/agents/regression-range/hackbot_agents/regression_range/__main__.py @@ -0,0 +1,58 @@ +from hackbot_runtime import HackbotContext, run_async +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .agent import RegressionRangeResult, run_regression_range + + +class AgentInputs(BaseSettings): + bug_id: int + bugzilla_mcp_url: str + # Optional bisection bounds (date, version number, or changeset). When + # omitted, the agent infers them from the bug. + good: str | None = None + bad: str | None = None + model: str | None = None + # Model the nested mozregression --prompt agent uses; defaults to `model`. + mozregression_model: str | None = None + max_turns: int | None = None + effort: str | None = None + + model_config = SettingsConfigDict(extra="ignore") + + @field_validator( + "good", "bad", "model", "mozregression_model", "effort", mode="before" + ) + @classmethod + def _empty_to_none(cls, v: object) -> object: + # compose passes optional inputs as empty strings when unset; treat those + # as absent so the agent infers bounds / uses defaults. + if isinstance(v, str) and not v.strip(): + return None + return v + + +async def main(ctx: HackbotContext) -> RegressionRangeResult: + inputs = AgentInputs() + + return await run_regression_range( + bugzilla_mcp_server={ + "type": "http", + "url": inputs.bugzilla_mcp_url, + }, + bug=inputs.bug_id, + anthropic_api_key=ctx.anthropic.api_key, + good=inputs.good, + bad=inputs.bad, + model=inputs.model, + mozregression_model=inputs.mozregression_model, + max_turns=inputs.max_turns, + effort=inputs.effort, + log=ctx.log_path, + verbose=True, + actions_recorder=ctx.actions, + ) + + +if __name__ == "__main__": + run_async(main) diff --git a/agents/regression-range/hackbot_agents/regression_range/agent.py b/agents/regression-range/hackbot_agents/regression_range/agent.py new file mode 100644 index 0000000000..4c6d466378 --- /dev/null +++ b/agents/regression-range/hackbot_agents/regression_range/agent.py @@ -0,0 +1,240 @@ +"""Regression-range finder -- an agent that bisects Firefox regressions. + +Orchestrates a Claude agent that, given a Bugzilla regression bug, works out +good/bad bounds and a natural-language reproduction directive, then runs +``mozregression`` in ``--prompt`` mode (mozilla/mozregression#2197) to bisect the +regression and report the resulting changeset/pushlog range. It records a +Bugzilla comment with the range and, at high confidence, proposes field updates +(regressed_by / cf_has_regression_range / clearing regressionwindow-wanted). + +Bugzilla is reached via an out-of-process MCP broker (HTTP transport) that holds +the Bugzilla token -- the agent process itself never sees it. HGMO and Searchfox +are public and run in-process. The mozregression tool shells out to the CLI, +which drives its own headless Firefox + DevTools MCP to classify each build. +""" + +from __future__ import annotations + +import json +import re +import sys +import tempfile +from pathlib import Path + +from agent_tools import mozilla_vcs, mozregression, searchfox +from agent_tools.claude_sdk import build_sdk_server +from agent_tools.mozilla_vcs import MozillaVcsContext +from agent_tools.mozregression import MozregressionContext +from agent_tools.searchfox import SearchfoxContext +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + McpServerConfig, + ResultMessage, +) +from hackbot_runtime import ActionsRecorder, AgentError, HackbotAgentResult +from hackbot_runtime.actions import ACTIONS_SERVER_NAME +from hackbot_runtime.actions.claude_sdk import actions_server_for, actions_to_tool_names +from hackbot_runtime.claude import Reporter +from searchfox import AsyncSearchfoxClient + +from .config import ( + BUGZILLA_READ_TOOLS, + ENABLED_ACTION_TYPES, + MOZILLA_VCS_TOOLS, + MOZREGRESSION_TOOLS, + SEARCHFOX_TOOLS, +) + +HERE = Path(__file__).resolve().parent + +# The agent ends its final message with a fenced ```json block carrying the +# structured result; we parse the last such block so it is machine-consumable. +_JSON_BLOCK = re.compile(r"```json\s*(\{.*?\})\s*```", re.DOTALL) + + +class RegressionRangeResult(HackbotAgentResult): + bug_id: int + # One of: range_found | inconclusive | not_automatable | not_a_regression + status: str | None = None + pushlog_url: str | None = None + first_bad_changeset: str | None = None + last_good_changeset: str | None = None + # hg node when the range was narrowed to a single introducing changeset. + regressor_node: str | None = None + # Bug number that landed the regressor, when identified. + regressed_by_bug: int | None = None + good_bound: str | None = None + bad_bound: str | None = None + prompt_used: str | None = None + confidence: str | None = None + summary: str | None = None + # The agent's full final message, always present as a fallback. + result: str | None = None + + +def load_system_prompt(extra: str) -> str: + tmpl = (HERE / "prompts" / "system.md").read_text() + return tmpl.format(extra_instructions=extra or "(none)") + + +def parse_result(text: str | None) -> dict: + """Extract the structured result from the agent's final message, if present. + + Returns an empty dict when no parseable ```json block is found -- the raw + text is still preserved in ``RegressionRangeResult.result``. + """ + if not text: + return {} + matches = _JSON_BLOCK.findall(text) + if not matches: + return {} + try: + data = json.loads(matches[-1]) + except json.JSONDecodeError: + return {} + if not isinstance(data, dict): + return {} + + def _as_int(value): + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + return { + "status": data.get("status"), + "pushlog_url": data.get("pushlog_url"), + "first_bad_changeset": data.get("first_bad_changeset"), + "last_good_changeset": data.get("last_good_changeset"), + "regressor_node": data.get("regressor_node"), + "regressed_by_bug": _as_int(data.get("regressed_by_bug")), + "good_bound": data.get("good_bound"), + "bad_bound": data.get("bad_bound"), + "prompt_used": data.get("prompt_used"), + "confidence": data.get("confidence"), + "summary": data.get("summary"), + } + + +async def run_regression_range( + *, + bugzilla_mcp_server: McpServerConfig, + bug: int, + anthropic_api_key: str | None = None, + good: str | None = None, + bad: str | None = None, + instructions: str = "", + model: str | None = None, + mozregression_model: str | None = None, + max_turns: int | None = None, + effort: str | None = None, + verbose: bool = False, + log: Path | None = None, + actions_recorder: ActionsRecorder | None = None, +) -> RegressionRangeResult: + """Bisect a single Bugzilla regression bug with mozregression. + + Returns a :class:`RegressionRangeResult` on success; raises + :class:`AgentError` if the agent ends in an error. + """ + print(f"[regression_range] bisecting bug {bug}", file=sys.stderr) + + # Action-recording MCP server (in-process). Standalone/script runs pass + # actions_recorder=None and get a local recorder (no uploader). + actions_recorder, actions_server = actions_server_for( + actions_recorder, types=ENABLED_ACTION_TYPES + ) + enabled_action_tools = actions_to_tool_names(ENABLED_ACTION_TYPES) + + # In-process MCP servers. Searchfox and HGMO are public (no credentials). + searchfox_server = build_sdk_server( + "searchfox", SearchfoxContext(client=AsyncSearchfoxClient()), searchfox.TOOLS + ) + vcs_server = build_sdk_server("mozilla_vcs", MozillaVcsContext(), mozilla_vcs.TOOLS) + mozregression_server = build_sdk_server( + "mozregression", + MozregressionContext( + anthropic_api_key=anthropic_api_key, + default_model=mozregression_model or model, + ), + mozregression.TOOLS, + ) + + system_prompt = load_system_prompt(instructions) + + # No source checkout is needed; give the agent a scratch cwd for the CLI. + workdir = Path(tempfile.mkdtemp(prefix="regression-range-")) + + options = ClaudeAgentOptions( + system_prompt=system_prompt, + mcp_servers={ + "bugzilla": bugzilla_mcp_server, + "searchfox": searchfox_server, + "mozilla_vcs": vcs_server, + "mozregression": mozregression_server, + ACTIONS_SERVER_NAME: actions_server, + }, + cwd=str(workdir), + permission_mode="bypassPermissions", + allowed_tools=[ + "Read", + "Grep", + "Glob", + "Bash", + *BUGZILLA_READ_TOOLS, + *SEARCHFOX_TOOLS, + *MOZILLA_VCS_TOOLS, + *MOZREGRESSION_TOOLS, + *enabled_action_tools, + ], + model=model, + max_turns=max_turns, + **({"effort": effort} if effort else {}), + setting_sources=[], + ) + + bounds_hint = "" + if good or bad: + bounds_hint = ( + "\n\nBounds provided for this run (use them unless the bug clearly " + f"contradicts them): good={good or 'unspecified'}, " + f"bad={bad or 'unspecified'}." + ) + user_prompt = ( + f"Find the regression range for Bugzilla bug {bug}.\n\n" + "Fetch the bug with the bugzilla MCP tools, decide whether it is an " + "automatable regression, determine good/bad bounds, then run " + "mozregression to bisect it. Follow your system instructions." + bounds_hint + ) + + result_msg: ResultMessage | None = None + with Reporter(verbose=verbose, log_path=log) as reporter: + reporter.header(f"bug {bug}") + async with ClaudeSDKClient(options=options) as client: + await client.query(user_prompt) + async for msg in client.receive_response(): + reporter.message(msg) + if isinstance(msg, ResultMessage): + result_msg = msg + + if result_msg is None: + raise AgentError(f"bug {bug}: agent produced no result message") + if result_msg.is_error: + raise AgentError( + f"bug {bug} regression-range failed: " + f"{result_msg.result or result_msg.subtype}" + ) + + parsed = parse_result(result_msg.result) + + return RegressionRangeResult( + bug_id=bug, + result=result_msg.result, + num_turns=result_msg.num_turns, + total_cost_usd=result_msg.total_cost_usd, + **parsed, + ) diff --git a/agents/regression-range/hackbot_agents/regression_range/broker.py b/agents/regression-range/hackbot_agents/regression_range/broker.py new file mode 100644 index 0000000000..f10e69a637 --- /dev/null +++ b/agents/regression-range/hackbot_agents/regression_range/broker.py @@ -0,0 +1,71 @@ +"""Bugzilla MCP broker. + +Sidecar container that holds the Bugzilla API key and serves the +bugzilla MCP tools over HTTP. The agent process (in a sibling container +in the same Cloud Run Job task) reaches us at `127.0.0.1:/mcp`. +The agent container itself binds no Bugzilla credentials. +""" + +import logging +from contextlib import asynccontextmanager + +import bugsy +import uvicorn +from agent_tools import bugzilla +from agent_tools.bugzilla import BugzillaContext +from agent_tools.claude_sdk import build_sdk_server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette +from starlette.routing import Mount + +log = logging.getLogger("bugzilla-broker") + + +class BrokerInputs(BaseSettings): + bugzilla_api_url: str + bugzilla_api_key: str + host: str = "0.0.0.0" + port: int = 8765 + + model_config = SettingsConfigDict(extra="ignore") + + +def build_app(inputs: BrokerInputs) -> Starlette: + client = bugsy.Bugsy( + api_key=inputs.bugzilla_api_key, bugzilla_url=inputs.bugzilla_api_url + ) + ctx = BugzillaContext(client=client) + sdk_config = build_sdk_server("bugzilla", ctx, bugzilla.TOOLS) + mcp_server = sdk_config["instance"] + + manager = StreamableHTTPSessionManager(app=mcp_server, stateless=True) + + @asynccontextmanager + async def lifespan(app): + async with manager.run(): + log.info( + "bugzilla broker ready on %s:%d (read-only)", + inputs.host, + inputs.port, + ) + yield + + async def mcp_handler(scope, receive, send): + await manager.handle_request(scope, receive, send) + + return Starlette(routes=[Mount("/mcp", app=mcp_handler)], lifespan=lifespan) + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + inputs = BrokerInputs() + app = build_app(inputs) + uvicorn.run(app, host=inputs.host, port=inputs.port, log_config=None) + + +if __name__ == "__main__": + main() diff --git a/agents/regression-range/hackbot_agents/regression_range/config.py b/agents/regression-range/hackbot_agents/regression_range/config.py new file mode 100644 index 0000000000..c533351e92 --- /dev/null +++ b/agents/regression-range/hackbot_agents/regression_range/config.py @@ -0,0 +1,45 @@ +# Bugzilla MCP tool names as exposed to the agent (mcp____). +BUGZILLA_READ_TOOLS = [ + "mcp__bugzilla__search_bugs", + "mcp__bugzilla__get_bugs", + "mcp__bugzilla__get_bug_comments", + "mcp__bugzilla__get_bug_attachments", + "mcp__bugzilla__download_attachment", +] + + +# Searchfox code-search tools (in-process MCP server "searchfox"). Used here to +# map a regressor changeset to the bug that landed it (and vice versa) and to +# read blame when narrowing a range. +SEARCHFOX_TOOLS = [ + "mcp__searchfox__search_identifier", + "mcp__searchfox__search_text", + "mcp__searchfox__find_definition", + "mcp__searchfox__get_function_at_line", + "mcp__searchfox__get_blame", + "mcp__searchfox__get_file", +] + +# Mozilla VCS / HGMO tools (in-process MCP server "mozilla_vcs"). Read a +# changeset's metadata/diff and file history over HTTP -- used to interpret the +# range mozregression returns and to find the regressor bug for a changeset. +MOZILLA_VCS_TOOLS = [ + "mcp__mozilla_vcs__get_commit_info", + "mcp__mozilla_vcs__get_commit_diff", + "mcp__mozilla_vcs__file_history", +] + +# mozregression bisection tool (in-process MCP server "mozregression"). +MOZREGRESSION_TOOLS = [ + "mcp__mozregression__run_mozregression", +] + + +# Recordable action types the agent may take, by dotted id. This agent reports a +# regression range: it records a comment with the range and, at high confidence, +# proposes field updates (regressed_by / cf_has_regression_range / clearing the +# regressionwindow-wanted keyword). It never creates bugs or attaches files. +ENABLED_ACTION_TYPES = [ + "bugzilla.add_comment", + "bugzilla.update_bug", +] diff --git a/agents/regression-range/hackbot_agents/regression_range/prompts/system.md b/agents/regression-range/hackbot_agents/regression_range/prompts/system.md new file mode 100644 index 0000000000..d9fd8817ae --- /dev/null +++ b/agents/regression-range/hackbot_agents/regression_range/prompts/system.md @@ -0,0 +1,109 @@ +You are an autonomous agent that finds the **regression range** for a Firefox regression bug, operating against a Bugzilla instance. + +# Your job + +You are given a bug ID. Your job is to bisect the regression with `mozregression` and report the range. Specifically: + +1. **Fetch** the bug (fields + comments) using the `bugzilla` MCP tools. +2. **Decide whether the bug is an automatable regression** (see the scoping section). If it isn't, stop and report — do **not** run mozregression. +3. **Determine good/bad bounds** for the bisection. +4. **Author a natural-language good/bad reproduction directive** and run `mozregression`. +5. **Report the range**: record a Bugzilla comment, propose field updates at high confidence, and end with the structured JSON block. + +# Bugzilla MCP tools — important quirks + +- **Always request `keywords` explicitly** in `include_fields`, and also `regressed_by`, `regressions`, `cf_has_regression_range`, and `version`. This Bugzilla proxy drops `keywords`/`whiteboard` from `_all` / `_default`. +- **The history endpoint is not exposed** on this proxy. Infer history from comments. +- **Bulk fetch whenever possible.** `get_bugs` takes a list of IDs in one request; don't loop single IDs. +- **Inaccessible bugs are silently dropped** (reported under `inaccessible`) — log and skip. + +Use **only** these tools for accessing Bugzilla. + +# Scoping — is this bug automatable? + +`mozregression`'s `--prompt` mode classifies each candidate build by **driving headless Firefox via the DevTools MCP** and returning GOOD/BAD. The DevTools MCP can drive far more than web content: + +- **Any page Firefox can load**, including privileged `about:`/chrome pages (`about:preferences`, `about:newtab`, `about:config`, …). +- **Browser chrome UI** — menus, tabs, the toolbar, sidebars, panels, context menus, and other front-end surfaces. + +So "it's a chrome/`about:` page" or "it's a browser-UI issue (a menu, tab, sidebar, or toolbar)" is **not** by itself a reason to decline — those are automatable. A Settings-UI element, a New-Tab-UI element, a menu/toolbar/sidebar state check, etc. can all be verified. + +The real constraint is **deterministic reproducibility in a fresh, headless profile**. A bug is automatable when its good/bad outcome is observable and stable given only prefs you can set — e.g. an element/control/text/layout that is deterministically present or absent, a scroll/DOM/JS behavior on a given URL or `about:` page. Feature state behind a pref is fine: set it via the `prefs` argument to `run_mozregression`. + +Set `status` and stop early (record a brief comment explaining why, but do **not** run mozregression) when: + +- The bug is **not a regression** (no evidence something that used to work now fails, and no `regressed_by` / `regression` / `regressionwindow-wanted` signal) → `status = "not_a_regression"`. +- The repro is **genuinely not reproducible this way** — a crash; an installer/updater/OS-integration issue; a bug with no usable steps; or one whose outcome depends on state you cannot recreate in a fresh headless profile (live server/recommendation content, region gating, Nimbus/experiment enrollment, sign-in, or a captcha). A pref-gated feature is **not** in this bucket — set the pref and proceed → `status = "not_automatable"`. + +Otherwise proceed to bisection. When in doubt about a pref-gated but otherwise deterministic repro, prefer to attempt it: mozregression verifies the good and bad builds up front and fails fast if the check can't distinguish them. + +# Determining good/bad bounds + +`mozregression` accepts a **date (YYYY-MM-DD)**, a **Firefox version number** (e.g. `123`), or a **changeset** for each of `--good` / `--bad`. + +- Use the `good` / `bad` values provided for the run if present. +- Otherwise infer them from the bug: "worked in Firefox N, broke in N+1" → good `N`, bad `N+1`; first-seen dates in comments/crash-stats; the landing window of a suspected regressor. +- **If a regressor is named or suspected** (a `regressed_by` entry, or a comment asking "was the regressor bug NNNNN?"), look up **when that bug landed** — read its comments for the hg node, then use `mozilla_vcs.get_commit_info(node)` for the push date. Set `good` **just before** that landing and `bad` **just after** (or the reported affected build). This is the tightest, most reliable window. Do **not** pick a `good` bound that is _after_ the suspected regressor landed — that build is already bad and mozregression will reject it. +- Prefer version numbers or dates. Keep the window as tight as the evidence supports, but make sure `good` really was good and `bad` really is bad. +- The DevTools MCP requires **Firefox 100+**; do not pick a `good` bound older than that. If the regression clearly predates Firefox 100, report `status = "inconclusive"` and explain. + +# Resolving prefs and feature gating — use searchfox, never guess + +If the repro depends on a feature flag or pref (e.g. "settings redesign enabled", "the X promo shown", an experiment toggle), you must find the **exact pref name(s)** before bisecting — a wrong pref means the feature never renders and every build looks the same, wasting a full bisection. + +- Use the `searchfox` tools to resolve prefs against real source: `search_text` / `search_identifier` for the feature's code and for the pref definition in `modules/libpref/init/all.js`, `StaticPrefList.yaml`, or the component's `.sys.mjs`/`.jsx`. Confirm the pref's **name** and its **default** in the affected builds. +- Trace the code path that gates the buggy element (as you would for triage) to learn exactly which prefs/state must be set for it to appear, then pass those in `prefs`. +- **Never invent a plausible-looking pref name.** If searchfox cannot confirm the required pref(s), say so and report `status = "not_automatable"` rather than guessing and running a doomed bisection. + +# Running mozregression + +Call `mozregression.run_mozregression` with: + +- `good` / `bad` — the bounds above. +- `prompt` — a concise directive in strict good/bad form, e.g. _"Navigate to https://example.com/page and click the Foo button. GOOD if the dialog opens; BAD if nothing happens or the page throws."_ Make the GOOD and BAD conditions unambiguous and observable from the page. + - **For layout/viewport-dependent symptoms** (scrolling, overflow, wrapping, clipping, responsive breakpoints), tell the check to **first resize the browser window to a realistic size** (e.g. ~1000×700) before measuring. A tall headless viewport can fit content that would otherwise overflow/scroll, hiding the symptom and making the affected build look GOOD — which stalls the bisection. +- `url` — the page to open, when the check targets a specific URL or `about:` page. +- `prefs` — the Firefox prefs the repro needs, using the **exact names you confirmed via searchfox** (see "Resolving prefs and feature gating"). Do not pass guessed pref names. + +Bisection is slow — it downloads and tests many builds. Call the tool once with good bounds rather than retrying with tiny tweaks. The tool returns `last_good`, `first_bad`, `pushlog_url`, and sometimes a `regressor_bug`; it never raises — inspect `success` and `message`. + +If the tool cannot narrow a range (`success` is false, or `first_bad`/`pushlog_url` are missing), report `status = "inconclusive"` with what you learned. + +# Recording actions + +The `actions` MCP tools (`bugzilla_add_comment`, `bugzilla_update_bug`) do **not** mutate Bugzilla — they record an intended action into `summary.json` for a human reviewer (or a downstream apply step). Treat each as a final, irrevocable proposal. + +When you find a range (`status = "range_found"`): + +- Record exactly one `bugzilla_add_comment` stating the pushlog range (link it), the last-good and first-bad changesets, the bounds and directive you used, and the regressor bug if identified. Be brief. +- Only when confidence is **high**, also record one `bugzilla_update_bug` that sets `cf_has_regression_range` to `"yes"`, adds the regressor to `regressed_by` if you identified its bug (`{{"regressed_by": {{"add": [NNN]}}}}`), and removes the `regressionwindow-wanted` keyword if present (`{{"keywords": {{"remove": ["regressionwindow-wanted"]}}}}`). Never record `status: RESOLVED`. + +When you did not find a range, record a brief comment explaining what you tried and what blocked it; do not record field updates. + +The `reasoning` parameter on every action is required — fill it properly. Do **not** record private comments. + +# Final message: structured result + +End your final message with a fenced ```json block using exactly these keys: + +```json +{{ + "status": "range_found | inconclusive | not_automatable | not_a_regression", + "summary": "one-line restatement of the bug", + "good_bound": "the --good value used, or null", + "bad_bound": "the --bad value used, or null", + "prompt_used": "the good/bad directive you gave mozregression, or null", + "pushlog_url": "the pushlog range URL, or null", + "last_good_changeset": "hg node, or null", + "first_bad_changeset": "hg node, or null", + "regressor_node": "single introducing changeset if narrowed, or null", + "regressed_by_bug": 123456, + "confidence": "high | medium | low" +}} +``` + +Set fields you could not determine to null (`regressed_by_bug` may be null). Keep `confidence` honest: `high` only when the range is tight and the good/bad verdicts were clear. + +# Additional instructions for this run + +{extra_instructions} diff --git a/agents/regression-range/pyproject.toml b/agents/regression-range/pyproject.toml new file mode 100644 index 0000000000..49c48b60cb --- /dev/null +++ b/agents/regression-range/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "hackbot-agent-regression-range" +version = "0.1.0" +description = "Cloud Run Job image that runs the regression-range agent for hackbot-api" +requires-python = ">=3.12" +dependencies = [ + "hackbot-runtime[claude-sdk]", + "agent-tools[bugzilla,searchfox,vcs]", + "bugsy", + "claude-agent-sdk>=0.1.30", + "mcp>=1.0.0", + "starlette>=0.36.0", + "uvicorn>=0.27.0", +] + +[tool.uv.sources] +hackbot-runtime = { workspace = true } +agent-tools = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["hackbot_agents"] diff --git a/docker-compose.yml b/docker-compose.yml index 7c804a95d4..1e68bdb253 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ include: - path: agents/bug-fix/compose.yml - path: agents/build-repair/compose.yml - path: agents/frontend-triage/compose.yml + - path: agents/regression-range/compose.yml - path: agents/test-plan-generator/compose.yml services: diff --git a/libs/agent-tools/agent_tools/mozregression.py b/libs/agent-tools/agent_tools/mozregression.py new file mode 100644 index 0000000000..f149420ae6 --- /dev/null +++ b/libs/agent-tools/agent_tools/mozregression.py @@ -0,0 +1,273 @@ +"""Run a mozregression bisection as an agent tool. + +Wraps the ``mozregression`` CLI so an agent can bisect a Firefox regression and +get back a changeset/pushlog range. The verdict for each candidate build is +produced by mozregression's ``--prompt`` mode (mozilla/mozregression#2197): the +tool hands mozregression a natural-language good/bad instruction, and +mozregression drives the Firefox DevTools MCP (via the Claude CLI) to classify +each build -- the natural-language equivalent of ``git bisect run``. + +The CLI, the ``claude`` binary, ``npx`` and the Firefox DevTools MCP are provided +by the agent's container image (not a Python dependency here). Bisection is slow +and downloads real builds, so the handler is a long-running subprocess wrapper +that -- like ``firefox.build_firefox`` -- ALWAYS returns a structured dict and +never raises: the agent inspects ``success`` / ``message`` and the parsed range. +""" + +from __future__ import annotations + +import asyncio +import os +import re +from dataclasses import dataclass +from typing import Annotated, Any + +from pydantic import Field + +from agent_tools.registry import tool, tools_in + +# mozregression prints the final range near the end of its output. The wording +# depends on the mode: a regression bisection reports ``Last good revision`` / +# ``First bad revision``, while ``--find-fix`` reports ``First good revision`` / +# ``Last bad revision``. It also prints several intermediate ``Pushlog:`` URLs as +# it narrows, so we take the LAST one (the final range) rather than the first. +_GOOD_RE = re.compile(r"(?:Last|First) good revision:\s*([0-9a-fA-F]{7,40})") +_BAD_RE = re.compile(r"(?:First|Last) bad revision:\s*([0-9a-fA-F]{7,40})") +_PUSHLOG_RE = re.compile(r"(https?://\S*pushlog\S*)") +_PUSHLOG_RANGE_RE = re.compile( + r"fromchange=([0-9a-fA-F]+)&(?:amp;)?tochange=([0-9a-fA-F]+)" +) +_REGRESSOR_BUG_RE = re.compile(r"show_bug\.cgi\?id=(\d+)") + + +def _parse_range(text: str) -> dict: + """Scrape the final good/bad changesets and pushlog URL from CLI output. + + Handles both regression (``Last good`` / ``First bad``) and ``--find-fix`` + (``First good`` / ``Last bad``) wording, and always takes the last pushlog + URL. Falls back to the pushlog URL's ``fromchange``/``tochange`` when the + labelled revision lines are absent. Returns keys ``last_good``, ``first_bad``, + ``pushlog_url`` (all ``None`` when not found). + """ + good = _GOOD_RE.findall(text) + bad = _BAD_RE.findall(text) + pushlogs = _PUSHLOG_RE.findall(text) + pushlog_url = pushlogs[-1] if pushlogs else None + + last_good = good[-1] if good else None + first_bad = bad[-1] if bad else None + + if (last_good is None or first_bad is None) and pushlog_url: + m = _PUSHLOG_RANGE_RE.search(pushlog_url) + if m: + # Pushlog range is fromchange=&tochange=; for a + # regression is the last good and the first bad. For + # --find-fix the roles invert (older=last bad, newer=first good), but + # the labelled lines above cover that case, so this fallback only + # fires for a plain regression range. + last_good = last_good or m.group(1) + first_bad = first_bad or m.group(2) + + return { + "last_good": last_good, + "first_bad": first_bad, + "pushlog_url": pushlog_url, + } + + +@dataclass +class MozregressionContext: + """Configuration for driving the mozregression CLI. + + ``anthropic_api_key`` is injected into the subprocess environment so the + nested ``claude`` CLI that mozregression's ``--prompt`` mode spawns can + authenticate; the agent process's own key is reused. No other credentials + are needed (build downloads and HGMO are public). + """ + + anthropic_api_key: str | None = None + app: str = "firefox" + default_model: str | None = None + headless: bool = True + # Bisection downloads and tests many builds; allow a very large ceiling. + timeout: float = 3 * 60 * 60 + # Cap captured output so a chatty run can't blow up the agent's context. + max_output_bytes: int = 40_000 + executable: str = "mozregression" + + +def _tail(text: str, limit: int) -> str: + """Keep the last ``limit`` bytes of output (the range is printed at the end).""" + raw = text.encode("utf-8", errors="ignore") + if len(raw) <= limit: + return text + return "......\n" + raw[-limit:].decode("utf-8", errors="ignore") + + +@tool +async def run_mozregression( + ctx: MozregressionContext, + good: Annotated[ + str, + Field( + description=( + "The last known-good bound: a date (YYYY-MM-DD), a Firefox " + "version number (e.g. '123'), or a changeset hash. Must be older " + "than 'bad' for a regression." + ) + ), + ], + bad: Annotated[ + str, + Field( + description=( + "The first known-bad bound: a date (YYYY-MM-DD), a Firefox " + "version number, or a changeset hash." + ) + ), + ], + prompt: Annotated[ + str, + Field( + description=( + "Natural-language good/bad instruction driving the Firefox " + "DevTools MCP, in a form that yields a clear verdict, e.g. " + "'Navigate to and do X. GOOD if , " + "BAD if .'" + ) + ), + ], + url: Annotated[ + str | None, + Field( + description=( + "Optional URL to open the build on (passed to Firefox via " + "'--arg'). Include it here when the check needs a specific page." + ) + ), + ] = None, + prefs: Annotated[ + dict[str, str] | None, + Field( + description=( + "Optional Firefox preferences to set for every candidate build, " + "as {pref_name: value} (passed as '--pref name:value')." + ) + ), + ] = None, + app: Annotated[ + str | None, + Field(description="Application to bisect; defaults to 'firefox'."), + ] = None, + repo: Annotated[ + str | None, + Field( + description=( + "Optional integration branch to bisect (e.g. 'autoland'); " + "defaults to mozregression's own choice." + ) + ), + ] = None, + find_fix: Annotated[ + bool, + Field( + description=( + "Set true to bisect for a fix instead of a regression (reverses " + "the good/bad ordering)." + ) + ), + ] = False, +) -> dict[str, Any]: + """Bisect a Firefox regression with mozregression and return the range. + + Runs mozregression in ``--prompt`` mode between ``good`` and ``bad``, using + the natural-language ``prompt`` to classify each build. Returns a dict with + ``success``, the parsed ``last_good`` / ``first_bad`` changesets, + ``pushlog_url``, a ``regressor_bug`` if mozregression mapped the range to a + single bug, and ``stdout`` / ``stderr`` tails. Always returns a dict; never + raises. Bisection is slow (it downloads and tests many builds). + """ + argv: list[str] = [ + ctx.executable, + "--app", + app or ctx.app, + "--good", + good, + "--bad", + bad, + "--prompt", + prompt, + ] + if ctx.headless: + argv.append("--prompt-headless") + if ctx.default_model: + argv += ["--prompt-model", ctx.default_model] + if url: + argv += ["--arg", url] + for name, value in (prefs or {}).items(): + argv += ["--pref", f"{name}:{value}"] + if repo: + argv += ["--repo", repo] + if find_fix: + argv.append("--find-fix") + + env = os.environ.copy() + if ctx.anthropic_api_key: + env["ANTHROPIC_API_KEY"] = ctx.anthropic_api_key + + try: + process = await asyncio.create_subprocess_exec( + *argv, + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + return { + "success": False, + "message": ( + f"mozregression executable '{ctx.executable}' not found on PATH" + ), + } + + try: + stdout_b, stderr_b = await asyncio.wait_for( + process.communicate(), timeout=ctx.timeout + ) + except asyncio.TimeoutError: + process.kill() + await process.wait() + return { + "success": False, + "message": f"mozregression timed out after {ctx.timeout:.0f}s", + } + + stdout = stdout_b.decode("utf-8", errors="ignore") if stdout_b else "" + stderr = stderr_b.decode("utf-8", errors="ignore") if stderr_b else "" + combined = f"{stdout}\n{stderr}" + + parsed = _parse_range(combined) + regressor_bug = _REGRESSOR_BUG_RE.search(combined) + + resolved = { + "success": process.returncode == 0, + "returncode": process.returncode, + "last_good": parsed["last_good"], + "first_bad": parsed["first_bad"], + "pushlog_url": parsed["pushlog_url"], + "regressor_bug": int(regressor_bug.group(1)) if regressor_bug else None, + "command": argv[:1] + ["--app", app or ctx.app, "--good", good, "--bad", bad], + "stdout": _tail(stdout, ctx.max_output_bytes), + "stderr": _tail(stderr, ctx.max_output_bytes), + } + if resolved["success"]: + resolved["message"] = "mozregression completed" + else: + resolved["message"] = ( + f"mozregression exited with code {process.returncode}; " + "the range may be incomplete" + ) + return resolved + + +TOOLS = tools_in(__name__) diff --git a/libs/agent-tools/tests/test_mozregression.py b/libs/agent-tools/tests/test_mozregression.py new file mode 100644 index 0000000000..8415f3836b --- /dev/null +++ b/libs/agent-tools/tests/test_mozregression.py @@ -0,0 +1,157 @@ +"""Tests for the mozregression bisection tool.""" + +import agent_tools.mozregression as mozreg_mod +from agent_tools import mozregression +from agent_tools.claude_sdk import build_sdk_server +from agent_tools.mozregression import ( + MozregressionContext, + _parse_range, + run_mozregression, +) +from mcp.types import ListToolsRequest + + +async def _list(server): + return ( + await server.request_handlers[ListToolsRequest]( + ListToolsRequest(method="tools/list") + ) + ).root.tools + + +async def test_exposes_mozregression_tool(): + ctx = MozregressionContext() + config = build_sdk_server("mozregression", ctx, mozregression.TOOLS) + assert config["type"] == "sdk" + tools = await _list(config["instance"]) + assert {t.name for t in tools} == {"run_mozregression"} + + +class _FakeProcess: + def __init__(self, stdout: bytes, stderr: bytes, returncode: int): + self._stdout = stdout + self._stderr = stderr + self.returncode = returncode + + async def communicate(self): + return self._stdout, self._stderr + + +_SAMPLE_OUTPUT = b"""\ + 0:00.00 INFO: Testing good and bad builds to ensure that they are correct. + 0:10.00 INFO: Last good revision: abcdef1234567 + 0:10.00 INFO: First bad revision: 1234567abcdef + 0:10.00 INFO: Pushlog: +https://hg.mozilla.org/integration/autoland/pushloghtml?fromchange=abcdef1234567&tochange=1234567abcdef + + 0:10.00 INFO: Looks like the following bug has the changes which introduced the regression: +https://bugzilla.mozilla.org/show_bug.cgi?id=1899999 +""" + + +async def test_parses_range_from_output(monkeypatch): + captured = {} + + async def fake_exec(*argv, **kwargs): + captured["argv"] = argv + captured["env"] = kwargs.get("env") + return _FakeProcess(_SAMPLE_OUTPUT, b"", 0) + + monkeypatch.setattr(mozreg_mod.asyncio, "create_subprocess_exec", fake_exec) + + ctx = MozregressionContext(anthropic_api_key="sk-test") + result = await run_mozregression( + ctx, + good="123", + bad="124", + prompt="Open the page. GOOD if it loads, BAD if it errors.", + url="https://example.com", + prefs={"foo.bar": "true"}, + ) + + assert result["success"] is True + assert result["last_good"] == "abcdef1234567" + assert result["first_bad"] == "1234567abcdef" + assert result["pushlog_url"].startswith("https://hg.mozilla.org/") + assert result["regressor_bug"] == 1899999 + + argv = captured["argv"] + assert "--prompt" in argv + assert "--good" in argv and "123" in argv + assert "--bad" in argv and "124" in argv + assert "--arg" in argv and "https://example.com" in argv + assert "--pref" in argv and "foo.bar:true" in argv + assert "--prompt-headless" in argv + # The Anthropic key is injected for the nested claude CLI. + assert captured["env"]["ANTHROPIC_API_KEY"] == "sk-test" + + +async def test_nonzero_exit_never_raises(monkeypatch): + async def fake_exec(*argv, **kwargs): + return _FakeProcess(b"some progress\n", b"boom\n", 2) + + monkeypatch.setattr(mozreg_mod.asyncio, "create_subprocess_exec", fake_exec) + + ctx = MozregressionContext() + result = await run_mozregression( + ctx, good="2024-01-01", bad="2024-02-01", prompt="check" + ) + + assert result["success"] is False + assert result["returncode"] == 2 + assert result["last_good"] is None + assert "exited with code 2" in result["message"] + + +# Trimmed from a real `--find-fix` run: several intermediate Pushlog lines while +# narrowing, then the final range. find-fix reports "First good"/"Last bad". +_FIND_FIX_OUTPUT = """\ + 0:00 INFO: Testing good and bad builds. + 0:10 INFO: Pushlog: +https://hg.mozilla.org/mozilla-central/pushloghtml?fromchange=aaaaaaaaaaaa&tochange=bbbbbbbbbbbb + 5:00 INFO: Agent verdict: build is good + 5:01 INFO: Narrowed integration fix window ... + 5:01 INFO: Pushlog: +https://hg.mozilla.org/integration/autoland/pushloghtml?fromchange=f8744ce82ac4&tochange=3173defff923 + 9:00 INFO: Agent verdict: build is bad + 9:01 INFO: No more integration revisions, bisection finished. + 9:01 INFO: First good revision: 3173defff92364ba83f3535ca8f751720dd14eda + 9:01 INFO: Last bad revision: d39b1b8814837efcc71cebc60cc1ea2f94b135de + 9:01 INFO: Pushlog: +https://hg.mozilla.org/integration/autoland/pushloghtml?fromchange=d39b1b881483&tochange=3173defff923 +""" + + +def test_parse_range_find_fix_wording_and_last_pushlog(): + parsed = _parse_range(_FIND_FIX_OUTPUT) + # find-fix wording is understood... + assert parsed["last_good"] == "3173defff92364ba83f3535ca8f751720dd14eda" + assert parsed["first_bad"] == "d39b1b8814837efcc71cebc60cc1ea2f94b135de" + # ...and the LAST (final) pushlog wins over the intermediate ones. + assert parsed["pushlog_url"].endswith( + "fromchange=d39b1b881483&tochange=3173defff923" + ) + + +def test_parse_range_pushlog_fallback_when_no_labelled_lines(): + text = ( + "INFO: Pushlog:\n" + "https://hg.mozilla.org/mozilla-central/pushloghtml?" + "fromchange=1111111111&tochange=2222222222\n" + ) + parsed = _parse_range(text) + assert parsed["last_good"] == "1111111111" + assert parsed["first_bad"] == "2222222222" + + +async def test_missing_executable_returns_error(monkeypatch): + async def fake_exec(*argv, **kwargs): + raise FileNotFoundError("mozregression") + + monkeypatch.setattr(mozreg_mod.asyncio, "create_subprocess_exec", fake_exec) + + ctx = MozregressionContext(executable="mozregression") + result = await run_mozregression(ctx, good="1", bad="2", prompt="check") + + assert result["success"] is False + assert "not found on PATH" in result["message"] diff --git a/services/hackbot-api/app/agents.py b/services/hackbot-api/app/agents.py index eb5aa804bb..5312f1557f 100644 --- a/services/hackbot-api/app/agents.py +++ b/services/hackbot-api/app/agents.py @@ -9,6 +9,7 @@ BugFixInputs, BuildRepairInputs, FrontendTriageInputs, + RegressionRangeInputs, TestPlanGeneratorInputs, ) @@ -79,6 +80,17 @@ def model_to_env(inputs: BaseModel) -> dict[str, str]: job_name="hackbot-agent-frontend-triage", input_schema=FrontendTriageInputs, ), + "regression-range": AgentSpec( + name="regression-range", + description=( + "Bisect a Firefox regression bug with mozregression (driven by a " + "natural-language reproduction check) and report the changeset/" + "pushlog range, recording a Bugzilla comment and proposed field " + "updates." + ), + job_name="hackbot-agent-regression-range", + input_schema=RegressionRangeInputs, + ), "test-plan-generator": AgentSpec( name="test-plan-generator", description=( diff --git a/services/hackbot-api/app/schemas.py b/services/hackbot-api/app/schemas.py index 5e3d85751a..81d4c40209 100644 --- a/services/hackbot-api/app/schemas.py +++ b/services/hackbot-api/app/schemas.py @@ -114,6 +114,18 @@ class FrontendTriageInputs(BaseModel): effort: str | None = None +class RegressionRangeInputs(BaseModel): + bug_id: int + # Optional bisection bounds (date, version number, or changeset); inferred + # from the bug when omitted. + good: str | None = None + bad: str | None = None + model: str | None = None + mozregression_model: str | None = None + max_turns: int | None = None + effort: str | None = None + + class TestPlanGeneratorInputs(BaseModel): feature_name: str feature_description: str diff --git a/services/hackbot-api/tests/test_agents.py b/services/hackbot-api/tests/test_agents.py index 1e782b9963..f5ae82df28 100644 --- a/services/hackbot-api/tests/test_agents.py +++ b/services/hackbot-api/tests/test_agents.py @@ -7,6 +7,7 @@ from app.schemas import ( BugFixInputs, BuildRepairInputs, + RegressionRangeInputs, ) from app.schemas import ( TestPlanGeneratorInputs as PlanGeneratorInputs, @@ -49,6 +50,25 @@ def test_build_repair_registry_entry(): assert spec.job_name == "hackbot-agent-build-repair" +def test_regression_range_registry_entry(): + spec = AGENT_REGISTRY["regression-range"] + assert spec.build_env is None + assert spec.input_schema is RegressionRangeInputs + assert spec.job_name == "hackbot-agent-regression-range" + # Actions are recorded for human review, never auto-applied. + assert spec.auto_apply_actions is False + + +def test_regression_range_env_serializes_optional_bounds(): + # bug_id only: optional bounds must not leak as env vars. + env = model_to_env(RegressionRangeInputs(bug_id=42)) + assert env == {"BUG_ID": "42"} + # When provided, bounds map to GOOD/BAD. + env = model_to_env(RegressionRangeInputs(bug_id=42, good="123", bad="124")) + assert env["GOOD"] == "123" + assert env["BAD"] == "124" + + def test_model_to_env_json_encodes_failure_tasks_and_bool(): tasks = {"build-linux64/opt": "OyF95j0oQ-CF_YuBM1b7vg"} env = model_to_env( diff --git a/services/hackbot-ui/lib/findings-format.tsx b/services/hackbot-ui/lib/findings-format.tsx index b2bddfb8ef..9701000c88 100644 --- a/services/hackbot-ui/lib/findings-format.tsx +++ b/services/hackbot-ui/lib/findings-format.tsx @@ -9,6 +9,13 @@ const LABEL_OVERRIDES: Record = { total_cost_usd: "Total Cost (USD)", num_turns: "Turns", regressor_node: "Regressor Node", + pushlog_url: "Pushlog Range", + first_bad_changeset: "First Bad Changeset", + last_good_changeset: "Last Good Changeset", + regressed_by_bug: "Regressed By (Bug)", + good_bound: "Good Bound", + bad_bound: "Bad Bound", + prompt_used: "Reproduction Directive", }; export function titleize(key: string): string { diff --git a/uv.lock b/uv.lock index 2024e35395..5a93cbd3d3 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,7 @@ members = [ "hackbot-agent-bug-fix", "hackbot-agent-build-repair", "hackbot-agent-frontend-triage", + "hackbot-agent-regression-range", "hackbot-agent-test-plan-generator", "hackbot-api", "hackbot-pulse-listener", @@ -2586,6 +2587,31 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.27.0" }, ] +[[package]] +name = "hackbot-agent-regression-range" +version = "0.1.0" +source = { editable = "agents/regression-range" } +dependencies = [ + { name = "agent-tools", extra = ["bugzilla", "searchfox", "vcs"] }, + { name = "bugsy" }, + { name = "claude-agent-sdk" }, + { name = "hackbot-runtime", extra = ["claude-sdk"] }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-tools", extras = ["bugzilla", "searchfox", "vcs"], editable = "libs/agent-tools" }, + { name = "bugsy" }, + { name = "claude-agent-sdk", specifier = ">=0.1.30" }, + { name = "hackbot-runtime", extras = ["claude-sdk"], editable = "libs/hackbot-runtime" }, + { name = "mcp", specifier = ">=1.0.0" }, + { name = "starlette", specifier = ">=0.36.0" }, + { name = "uvicorn", specifier = ">=0.27.0" }, +] + [[package]] name = "hackbot-agent-test-plan-generator" version = "0.1.0"