Skip to content
Open
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/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Code owners for specific Hackbot agents or tools
/agents/frontend-triage/ @mozilla/hackbot-frontend
/agents/perf-triage/ @mozilla/hackbot-performance
/agents/autowebcompat-repro/ @mozilla/hackbot-webcompat
/agents/autowebcompat-diagnosis/ @mozilla/hackbot-webcompat
/agents/autowebcompat-intervention/ @mozilla/hackbot-webcompat
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/hackbot-prod-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ on:
- agents/build-repair
- agents/dataset-eval
- agents/frontend-triage
- agents/perf-triage
- agents/test-plan-generator
- agents/test-repair
- agents/test-repair-fix
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ node_modules/

# Local env files
.env

/http_service/docker-compose.override.yml

# Local files
Expand Down
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Vendored verbatim from upstream repos (see PINNED.md there); reformatting them
# would break the byte-for-byte match with the pinned revision.
exclude: ^agents/perf-triage/hackbot_agents/perf_triage/(skills/|calculator/)
repos:
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
Expand Down
2 changes: 2 additions & 0 deletions .taskcluster.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ tasks:
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=hackbot_agents --cov-append tests/ &&
cd ../frontend-triage &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=hackbot_agents --cov-append tests/ &&
cd ../perf-triage &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=hackbot_agents --cov-append tests/ &&
cd ../test-repair &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=hackbot_agents --cov-append tests/ &&
cd ../test-repair-fix &&
Expand Down
4 changes: 4 additions & 0 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ the repo's root `docker-compose.yml`, so running it is three steps:
```dotenv
# .env (repo root) — never commit this file
ANTHROPIC_API_KEY=sk-ant-...
# NOTE the /rest suffix. This value is passed straight to bugsy as its
# bugzilla_url, so without /rest every read hits Bugzilla's HTML 404 page and
# fails as "Expecting value: line 1 column 1 (char 0)" — which looks like a
# transient parse error rather than a misconfiguration.
BUGZILLA_API_URL=https://bugzilla.mozilla.org/rest
BUGZILLA_API_KEY=...
```
Expand Down
131 changes: 131 additions & 0 deletions agents/perf-triage/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
FROM python:3.14 AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

ENV UV_PROJECT_ENVIRONMENT=/opt/venv

WORKDIR /app

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-perf-triage

# The agent's own source enters the image through this bind mount rather than a
# COPY, so its contents are NOT part of this layer's cache key: editing agent.py
# or anything under agents/perf-triage does not invalidate the layer, and a plain
# rebuild silently ships the previously-built code. That is harmless in CI
# (every build starts cold) but wrong for local iteration — it cost three real
# agent runs against a stale image before it was spotted in perf-repro.
#
# SOURCE_REF busts the cache without needing --no-cache (which would also
# discard the apt layers). compose.yml passes the current git HEAD, so any
# commit rebuilds the source layer and nothing else.
ARG SOURCE_REF=unknown
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,target=/app,rw \
echo "source ref: ${SOURCE_REF}" \
&& uv sync --locked --no-dev --no-editable --package hackbot-agent-perf-triage

FROM python:3.14 AS base

WORKDIR /app

ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PATH="/opt/venv/bin:$PATH"

FROM base AS agent

# profiler-cli for the triage skill's profile step: the profiler-analysis skill
# self-checks for it on PATH and hard-stops without it. No browser libraries --
# this agent never launches Firefox. jq: agent-mode Step 5 checks its JSON with it
# before emitting the result.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs npm ca-certificates jq \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @firefox-devtools/profiler-cli \
&& npm cache clean --force

# searchfox-cli, the second tool the skill reaches for. Unlike profiler-cli it
# does NOT hard-stop the skill when absent — it is one of three named routes for
# validating an inference (marker payloads and sample counts are the others), so
# the skill degrades rather than refusing. It is still worth having: --calls-from
# /--calls-to/--can-gc/--field-layout are exactly what root-cause work uses, and
# hackbot_runtime/searchfox.py exposes none of them.
#
# The plan assumed `cargo install`, which would mean a full Rust toolchain
# (rustc+cargo+libllvm is ~1.2GB unpacked) plus a from-source build on every
# cold image. Upstream publishes prebuilt static-ish Linux binaries per release,
# so this is a ~5MB tarball instead. Pinned to a version, not "latest", for the
# same reason the skill itself is pinned: upgrades arrive in a reviewable diff.
ARG SEARCHFOX_CLI_VERSION=0.20.4
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) target=x86_64-unknown-linux-gnu ;; \
arm64) target=aarch64-unknown-linux-gnu ;; \
*) echo "no searchfox-cli build for $arch" >&2; exit 1 ;; \
esac; \
url="https://github.com/padenot/searchfox-cli/releases/download/v${SEARCHFOX_CLI_VERSION}/searchfox-cli-${SEARCHFOX_CLI_VERSION}-${target}.tar.gz"; \
curl -fsSL "$url" -o /tmp/searchfox-cli.tar.gz; \
tar -xzf /tmp/searchfox-cli.tar.gz -C /tmp; \
install -m 0755 "$(find /tmp -type f -name searchfox-cli | head -1)" /usr/local/bin/searchfox-cli; \
rm -rf /tmp/searchfox-cli.tar.gz /tmp/searchfox-cli-*; \
searchfox-cli --version

# The profiler-analysis skill, vendored at a pinned mozilla-central revision in
# agents/perf-triage/hackbot_agents/perf_triage/skills/ (see PINNED.md there). The SDK discovers
# skills on the filesystem at <cwd>/.claude/skills/ and WORKDIR is /app, so this
# exact path is what makes ClaudeAgentOptions.skills=["profiler-analysis"] find
# it — proven in-container 2026-08-19.
#
# references/ is load-bearing, not documentation: the skill Globs that directory
# for the case study matching the scenario. Copying SKILL.md alone leaves the
# skill RUNNING but uncalibrated, which fails silently. Copy the whole tree.
COPY agents/perf-triage/hackbot_agents/perf_triage/skills/profiler-analysis \
/app/.claude/skills/profiler-analysis

# The triage-perf-bug skill (step 1), vendored at a pinned revision of
# performance-triage-calculator -- see PINNED.md. Same /app/.claude/skills/
# discovery path as profiler-analysis above. One file, no references/ tree.
COPY agents/perf-triage/hackbot_agents/perf_triage/skills/triage-perf-bug \
/app/.claude/skills/triage-perf-bug

# The calculator the skill shells out to. Vendored at the same revision, and
# REQUIRED: the skill resolves $PERF_TRIAGE_PATH first and otherwise falls back
# to its own repository root, which does not exist in this image -- so an unset
# PERF_TRIAGE_PATH means step 1 cannot run. Setting it here rather than in
# compose.yml makes the image self-sufficient; compose still passes it through
# so a local run can point at a working checkout instead.
COPY agents/perf-triage/hackbot_agents/perf_triage/calculator/perf_triage.py /app/perf_triage.py
# --classify-domain reads the pages-affected tiers from beside the script.
COPY agents/perf-triage/hackbot_agents/perf_triage/calculator/top_sites.json /app/top_sites.json
ENV PERF_TRIAGE_PATH=/app/perf_triage.py

# hackbot.toml lives at the agent root; the runtime discovers it in the cwd.
COPY agents/perf-triage/hackbot.toml /app/hackbot.toml

RUN useradd --create-home --shell /bin/bash agent \
&& mkdir -p /workspace \
&& chown agent:agent /workspace \
&& chown -R agent:agent /app/.claude

USER agent

COPY --from=builder /opt/venv /opt/venv

CMD ["python", "-m", "hackbot_agents.perf_triage"]

FROM base AS broker

RUN useradd --create-home --shell /bin/bash broker

USER broker

EXPOSE 8765

COPY --from=builder /opt/venv /opt/venv

CMD ["python", "-m", "hackbot_agents.perf_triage.broker"]
125 changes: 125 additions & 0 deletions agents/perf-triage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# perf-triage agent

Rates one Firefox performance bug with the Performance Impact Calculator (the
vendored `triage-perf-bug` skill, in agent mode) and writes the rating to the bug:
one comment, `cf_performance_impact`, and the calculator's keywords (added, never
removed). When the bug links a Firefox Profiler capture or has one attached, the skill analyses it with
`profiler-analysis` first, so the profile can change the rating.

## What it triages

This agent focuses on **Performance Bugs**.

What gets routed to this agent:
Bugs that have Performance Impact (`cf_performance_impact`) set to `?` and bugs that are added to `Core::Performance:General`.

## Running it locally

Needs Docker running (Compose v2), an Anthropic API key with billing enabled, and
a Bugzilla API key. Local runs only read, so a key from an account without edit
rights works. Put the secrets in a gitignored `.env` at the repo root:

```dotenv
ANTHROPIC_API_KEY=sk-ant-...
BUGZILLA_API_URL=https://bugzilla.mozilla.org/rest
BUGZILLA_API_KEY=...
```

`BUGZILLA_API_URL` must end in `/rest`. Then build and run from this directory:

```sh
cd agents/perf-triage
set -a; . ../../.env; set +a
export SOURCE_REF=$(git rev-parse HEAD) # a build arg; without it the image ships stale code
docker compose build
BUG_ID=1839859 docker compose run --rm perf-triage-agent
```

The root `docker-compose.yml` includes this `compose.yml`, so
`docker compose run --rm perf-triage-agent` also works from the repo root.
`docker compose up` does not rebuild an existing image, so build again after
changing code. The agent image needs no Firefox checkout or build. A run takes
2-3 minutes; a bug that links a profile takes longer, because profiler-cli loads it
whole in the container (deployed with 8Gi for that reason).

### Environment variables

`hackbot-api` derives them from the input schema. Locally they come from `.env`,
`compose.yml`, or the command line. Every `AgentInputs` field must also be listed
in `compose.yml`, or Docker drops it and the default applies silently.

| Env var | Required | Meaning |
| ------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `BUG_ID` | yes | The Bugzilla bug to triage |
| `ANTHROPIC_API_KEY` | yes | Drives the agent (billed per token, roughly $0.25-0.85 a bug) |
| `BUGZILLA_API_URL` | yes | e.g. `https://bugzilla.mozilla.org/rest`. **Broker container only** |
| `BUGZILLA_API_KEY` | yes | **Broker container only.** The agent reads Bugzilla through the broker and never sees the key |
| `BROKER_URL` | yes | Broker base URL; the agent appends `/mcp`. `compose.yml` sets it |
| `SOURCE_REF` | build | Build arg naming the commit the image is built from. Defaults to `dev` |
| `PERF_TRIAGE_PATH` | no | The calculator script. Defaults to the image's vendored `/app/perf_triage.py`; point it at a checkout to test an unreleased calculator |
| `MODEL` | no | Model override; unset uses the SDK default |
| `MAX_TURNS` | no | Hard cap on loop iterations, a runaway guard |
| `EFFORT` | no | `low` \| `medium` \| `high` \| `xhigh` \| `max` |
| `BLIND_BUG_ID` | no | Evaluation only: the broker serves that bug as it stood before it was triaged (`hackbot_agents/perf_triage/blind.py`) |

## Output

Each run writes to `~/hackbot/artifacts/<run_id>/`:

- **`summary.json`.** `findings` holds:

- `triage`: the skill's result (`impact`, `score`, `keywords`, `selections`, `unestablished`, the calculator comment and URL);
- `stability`: whether the rating holds across every value of the inputs the bug left open, plus `platforms`;
- `write_mode`: `full` for an unrated bug, `comment_only` for a rated one;
- `skipped_reason`;
- `auto_apply`.

`actions` holds the **recorded** comment and, in full mode, the field change.

- **`logs/agent.log`.** The streamed reasoning and every tool call.
- **A skipped run** has `skipped_reason` set and no actions. Causes: the bug lookup failed, triage failed, or a rated bug where the agent agrees with the existing rating.

## What it writes back to Bugzilla

**Nothing, during a run.** The two enabled action types, `bugzilla.add_comment`
and `bugzilla.update_bug`, only append to `summary.json`. Reads go through the
broker sidecar, which holds the API key.

**Afterwards, hackbot-api applies the recorded actions in one `PUT`.** It does this
unattended only when `findings.auto_apply` is true. That happens when the
calculator, re-scored over every unestablished input and every platform choice,
lands in a single band (`check_stability` in
`hackbot_agents/perf_triage/triage/stability.py`). Otherwise the actions wait for a person in
the Hackbot UI, and the comment says which inputs would change the rating.

What can be written depends on the bug's state when the run starts:

| Bug's `cf_performance_impact` | Writes |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `---` or `?` (unrated) | One comment; `cf_performance_impact`; the calculator's keywords (added, never removed); on `high`, a needinfo to the performance reviewer |
| Already rated by a person | One comment only if the agent's rating differs; nothing if it agrees. The field is never overwritten |

The hooks in `hooks.py` refuse anything else as it is recorded, so a refused
action never reaches `summary.json`. They are the only bound on what an unattended
run writes, because hackbot-api dispatches against a much wider handler registry:

- `add_comment_hook`: one public comment, on the triaged bug.
- `update_bug_hook`: one update, on the triaged bug, never in `comment_only` mode.
It touches only `cf_performance_impact` (one of `none`/`low`/`medium`/`high`),
`keywords` as `{"add": [...]}` limited to keywords the vendored calculator can
emit, and `flags` limited to exactly the reviewer needinfo, on a `high` rating.

The comment text is code (`comment.py`), not a model turn. It carries the
calculator's own comment and permalink, the stability verdict, and a line telling
anyone who disagrees whom to needinfo.

## Deployment prerequisites

- **The hackbot Bugzilla account must be in the `perf-triage-team` Bugzilla group**, with
edit rights to `cf_performance_impact`. hackbot-api sends the field, keywords and
needinfo in one `PUT`, so without them that update is rejected on every unrated
bug. Setting a needinfo needs no extra rights.
Confirm with one real write on a test bug after the first deploy.
- **The agent is deployed before the bugbot `perf_triage` rule is enabled.** The
rule's first production run uses `--backlog` to clear existing bugs; after that
it looks back one day.
1 change: 1 addition & 0 deletions agents/perf-triage/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.0
49 changes: 49 additions & 0 deletions agents/perf-triage/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
services:
perf-triage-broker:
build:
context: ../..
dockerfile: agents/perf-triage/Dockerfile
target: broker
args:
SOURCE_REF: ${SOURCE_REF:-dev}
env_file:
- path: ../../.env
required: false
# The broker is the only container that holds the Bugzilla credential; the
# agent reaches Bugzilla through it.
environment:
BUGZILLA_API_URL: ${BUGZILLA_API_URL}
BUGZILLA_API_KEY: ${BUGZILLA_API_KEY}
# Evaluation only (hackbot_agents/perf_triage/blind.py). Passed through only when set.
BLIND_BUG_ID:
expose:
- "8765"

perf-triage-agent:
build:
context: ../..
dockerfile: agents/perf-triage/Dockerfile
target: agent
args:
SOURCE_REF: ${SOURCE_REF:-dev}
env_file:
- path: ../../.env
required: false
# EVERY AgentInputs field must be listed here; Docker silently drops the rest.
environment:
- RUN_ID
- BUG_ID
- MODEL
- MAX_TURNS
- EFFORT
# A bare entry with the host variable unset overrides the Dockerfile ENV
# with an empty value, so default it to the image's copy.
- PERF_TRIAGE_PATH=${PERF_TRIAGE_PATH:-/app/perf_triage.py}
- BROKER_URL=http://perf-triage-broker:8765
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?error}
- ARTIFACTS_DIR=/artifacts
volumes:
- ${HOME}/hackbot/artifacts:/artifacts
depends_on:
perf-triage-broker:
condition: service_started
19 changes: 19 additions & 0 deletions agents/perf-triage/hackbot.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# perf-triage needs no platform prep: no [source] checkout, no [firefox] build.
# The bug comes from the request (bug_id) and triage reads it through the broker;
# a linked profile is fetched by profiler-cli at run time.

# Infrastructure this agent runs on, applied by Terraform. Changing anything here
# changes the agent's shape and goes through an infra PR; the image ships on
# its own.
#
# Local runs took 2-3 minutes. The memory is for profiler-cli, which loads a
# linked profile whole.
[deploy]
runtime = "cloud_run_job"
cpu = "2"
memory = "8Gi"
timeout_seconds = 1800

[deploy.broker]
cpu = "1"
memory = "512Mi"
Empty file.
Loading