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
64 changes: 64 additions & 0 deletions agents/webcompat-triage/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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-webcompat-triage

RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,target=/app,rw \
uv sync --locked --no-dev --no-editable --package hackbot-agent-webcompat-triage

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

# The Firefox DevTools MCP server is an npm package launched via `npx`, so the
# agent image needs Node.js + npm (the python base ships neither). It also
# needs the shared libraries Firefox requires to run headless; the Firefox
# binary itself is downloaded at agent startup (a fresh Nightly per run) via
# mozdownload/mozinstall, not baked in here.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
nodejs npm \
ca-certificates \
libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 libxtst6 libxt6 \
libasound2 libpci3 \
&& rm -rf /var/lib/apt/lists/*

# 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/webcompat-triage/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.webcompat_triage"]

FROM base AS broker

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

USER broker

EXPOSE 8765

CMD ["python", "-m", "hackbot_agents.webcompat_triage.broker"]
31 changes: 31 additions & 0 deletions agents/webcompat-triage/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
services:
webcompat-triage-broker:
build:
context: ../..
dockerfile: agents/webcompat-triage/Dockerfile
target: broker
environment:
BUGZILLA_API_URL: ${BUGZILLA_API_URL}
BUGZILLA_API_KEY: ${BUGZILLA_API_KEY}
expose:
- "8765"

webcompat-triage-agent:
build:
context: ../..
dockerfile: agents/webcompat-triage/Dockerfile
target: agent
environment:
- RUN_ID

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we prefix these either like AUTOWEBCOMPAT_ or BUGBUG_? We can change the settings class to strip the prefix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will not be necessary since this will not be shared with other agents or so. Keeping it shorter would make it cleaner when running it locally.

- BUG_DATA
- BUG_ID
- BUGZILLA_MCP_URL=http://webcompat-triage-broker:8765/mcp
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?error}
# No uploader locally: summary/logs/attachments are written under
# /artifacts/<run_id>, bind-mounted to the host's ~/hackbot/artifacts.
- ARTIFACTS_DIR=/artifacts
volumes:
- ${HOME}/hackbot/artifacts:/artifacts
depends_on:
webcompat-triage-broker:
condition: service_started
3 changes: 3 additions & 0 deletions agents/webcompat-triage/hackbot.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# webcompat-triage needs no platform prep: no [source] checkout, no [firefox] build.
# Subject comes from the request (bug_data / bug_id); the DevTools MCP drives a
# Firefox instance installed in the image.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from hackbot_runtime import HackbotContext, run_async
from pydantic_settings import BaseSettings, SettingsConfigDict

from .agent import WebcompatTriageResult, run_webcompat_triage
from .firefox_install import install_firefox_nightly


class AgentInputs(BaseSettings):
bugzilla_mcp_url: str
bug_data: str | None = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should consider making this explicitly a JSON object rather than just a string (although maybe in the end it doesn't make much difference?).

bug_id: int | None = None
model: str | None = None
max_turns: int | None = None
effort: str | None = None

model_config = SettingsConfigDict(extra="ignore")


async def main(ctx: HackbotContext) -> WebcompatTriageResult:
inputs = AgentInputs()

# Provision a fresh Nightly at startup so each run reproduces against a
# current build; drive the binary the install reports back.
firefox_path = str(install_firefox_nightly())

return await run_webcompat_triage(
bugzilla_mcp_server={
"type": "http",
"url": inputs.bugzilla_mcp_url,
},
bug_data=inputs.bug_data,
bug_id=inputs.bug_id,
model=inputs.model,
max_turns=inputs.max_turns,
effort=inputs.effort,
firefox_path=firefox_path,
log=ctx.log_path,
verbose=True,
)


if __name__ == "__main__":
run_async(main)
152 changes: 152 additions & 0 deletions agents/webcompat-triage/hackbot_agents/webcompat_triage/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Firefox web-compatibility triage agent.

Drives an agent that reproduces a broken-site report in Firefox
using the Firefox DevTools MCP. The bug is passed either inline as ``bug_data``
text or a Bugzilla ``bug_id`` (read via Bugzilla broker).
"""

from __future__ import annotations

import sys
from pathlib import Path

from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
McpServerConfig,
ResultMessage,
)
from hackbot_runtime import AgentError, HackbotAgentResult
from hackbot_runtime.claude import Reporter

from .config import BUGZILLA_READ_TOOLS, DEVTOOLS_TOOLS
from .devtools_mcp import build_devtools_server
from .result import (
RESULT_SERVER_NAME,
SUBMIT_RESULT_TOOL,
ResultCollector,
TriageResult,
build_result_server,
)

HERE = Path(__file__).resolve().parent


class WebcompatTriageResult(HackbotAgentResult):
result: TriageResult | None = None


def load_system_prompt() -> str:
return (HERE / "prompts" / "system.md").read_text()


def build_user_prompt(bug_data: str | None, bug_id: int | None) -> str:
if bug_data:
return (
"Here is the web-compatibility report to work on:\n\n"
f"{bug_data}\n\n"
"Follow your task procedure."
)
if bug_id is not None:
return (
f"The web-compatibility report to work on is Bugzilla bug {bug_id}. "
"Fetch it using the Bugzilla MCP tools, then follow your task procedure."
)
raise AgentError("neither bug_data nor bug_id was provided")


async def run_webcompat_triage(
*,
bugzilla_mcp_server: McpServerConfig,
bug_data: str | None = None,
bug_id: int | None = None,
model: str | None = None,
max_turns: int | None = None,
effort: str | None = None,
firefox_path: str | None = None,
verbose: bool = False,
log: Path | None = None,
) -> WebcompatTriageResult:
"""Reproduce a web-compat issue and return the agent's findings.

Returns a :class:`WebcompatTriageResult` on success; raises
:class:`AgentError` if the agent ends in an error.
"""
subject = bug_data if bug_data else f"bug {bug_id}"
print(f"[webcompat-triage] triaging {subject}", file=sys.stderr)

devtools_server = build_devtools_server(
firefox_path=Path(firefox_path) if firefox_path else None,
headless=True,
enable_script=True,
)

# Structured-result MCP server (in-process): the agent calls submit_result
# once at the end, giving a predictable JSON result instead of free text.
result_collector = ResultCollector()
result_server = build_result_server(result_collector)

# Only wire up Bugzilla when there's a bug to fetch. With inline bug_data
# there's nothing to read, so the bugzilla MCP is not available
mcp_servers: dict[str, McpServerConfig] = {
"firefox-devtools": devtools_server,
RESULT_SERVER_NAME: result_server,
}
bugzilla_tools: list[str] = []
if bug_id is not None:
mcp_servers["bugzilla"] = bugzilla_mcp_server
bugzilla_tools = BUGZILLA_READ_TOOLS

system_prompt = load_system_prompt()

options = ClaudeAgentOptions(
system_prompt=system_prompt,
mcp_servers=mcp_servers,
permission_mode="bypassPermissions",
allowed_tools=[
"Read",
"Grep",
"Glob",
"Bash",
*bugzilla_tools,
*DEVTOOLS_TOOLS,
SUBMIT_RESULT_TOOL,
],
model=model,
max_turns=max_turns,
**({"effort": effort} if effort else {}),
setting_sources=[],
# DevTools snapshots/screenshots of complex pages serialize to JSON that
# can exceed the SDK's default 1 MiB message buffer (the reader dies
# fatally if it does). Raise it well above that ceiling.
max_buffer_size=10 * 1024 * 1024,
)

user_prompt = build_user_prompt(bug_data, bug_id)

result_msg: ResultMessage | None = None
with Reporter(verbose=verbose, log_path=log) as reporter:
reporter.header(subject)
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"{subject}: agent produced no result message")
if result_msg.is_error:
raise AgentError(
f"{subject} investigation failed: {result_msg.result or result_msg.subtype}"
)
if result_collector.result is None:
raise AgentError(
f"{subject}: agent finished without submitting a result via submit_result"
)

return WebcompatTriageResult(
result=result_collector.result,
num_turns=result_msg.num_turns,
total_cost_usd=result_msg.total_cost_usd,
)

@ksy36 ksy36 Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like result of the run is stored as an artifact in a json file. We should decide what the result should look like. So for a sample triage run it produced this in summary.json:

{
  "status": "ok",
  "error": null,
  "findings": {
    "num_turns": 78,
    "total_cost_usd": 3.2635555,
    "mode": "triage",
    "result": "I've confirmed the reproduction. Here's my report:\n\n## Reproduction Report\n\n**URL:** https://aifaceswap.io/#face-swap-playground\n**Browser:** Firefox (headless via Firefox DevTools MCP)\n**Date tested:** 2026-06-15\n\n### Steps taken\n1. Navigated to the Face Swap Playground.\n2. Selected the Mona Lisa template via the \"use it\" button, then replaced the Original image by uploading `face.jpg` (a woman portrait fetched from the site's own template set).\n3. Uploaded `face2.jpg` (a man portrait, also from the site's templates) into the **Face image** slot.\n4. Clicked **Start face swapping**. The queue progressed from ~25% \u2192 95% \u2192 complete (~35 seconds).\n5. A swapped result image appeared in the right pane, with three action buttons below it: a small circular **Download** button (download icon), **Upscale**, and **Enhance**.\n6. Clicked the Download button (`<button class=\"btn btn-icon btn-sm btn-primary me-5\"><i class=\"bi bi-download\"></i></button>`).\n\n### Result \u2014 bug reproduced\nClicking Download **does nothing** in Firefox:\n- No file save dialog appears.\n- No new tab opens.\n- No network request is issued after the click.\n- No console messages are logged.\n- The page state is unchanged.\n\nI tried both a UID-targeted click via DevTools and a programmatic `.click()` from JavaScript \u2014 both produced no visible effect and no observable side effects.\n\nThe reported web-compatibility issue is reproducible as described."
  },
  "actions": []

The sample triage prompt didn't ask to leave a comment or attach testcases/screenshots, but if we add that and there is a bug id passed as well, these could be in the actions list:

ENABLED_ACTION_TYPES = [
    "bugzilla.update_bug",
    "bugzilla.add_comment",
    "bugzilla.add_attachment",
    "bugzilla.create_bug",
]

For example,

  "actions": [
    {
      "type": "bugzilla.add_comment",
      "params": {
        "bug_id": 2046445,
        "text": "Reproduced on Firefox Nightly using firefox-devtools MCP tooling on Linux.\n\n**Steps performed:**\n1. Navigated to https://aifaceswap.io/#face-swap-playground\n2. Uploaded two images via the \"Original image\" and \"Face image\" upload widgets (programmatically populated the two `input[type=\"file\"]` elements with image/webp files and dispatched a `change` event)\n3. Clicked \"Start face swapping\" \u2014 the face-swap pipeline ran, the progress bar advanced to completion, and the swapped image appeared in the result panel along with the action toolbar (download icon button, Upscale, Enhance)\n4. Clicked the download icon button (`button.btn.btn-icon.btn-sm.btn-primary.me-5` containing `<i class=\"bi bi-download\">`)\n\n**Result:** Nothing happens.\n- No file download is initiated (no \"Save As\" dialog, no entry added to downloads).\n- No new tab/page is opened.\n- No navigation occurs.\n- No visible UI change on the page.\n- No anchor element with a `download` attribute is programmatically clicked (verified by instrumenting `HTMLAnchorElement.prototype.click`).\n- No console messages are emitted.\n\nI also tried dispatching a full `mousedown` \u2192 `mouseup` \u2192 `click` MouseEvent sequence at the button's center coordinates in addition to a plain `.click()` \u2014 the behavior was identical (no download, no UI change).\n\nThis matches the actual behavior described in comment 0. Issue reproduces on the current site build.\n\n*This is an automated analysis result. If this result is incorrect please add a needinfo and feel free to correct the error.* ",
        "is_private": false
      },
      "reasoning": "Confirming reproduction of the broken download button for the webcompat triage. Reporting only what was observed, no root-cause analysis."
    }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference, which I don't think is universal, is that we try to get it to produce those artifacts without modelling it as an update to the bug (even if it doesn't actually perform the updates). The bug is intrinsically shared global state, and the more we depend on the model interacting with that directly, the harder it is likely to be to run experiments or use in non-bugzilla contexts (e.g. with the dashboard).

@ksy36 ksy36 Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I've changed it to this format for now (defined in result.py):

{
  "status": "ok",
  "error": null,
  "findings": {
    "num_turns": 19,
    "total_cost_usd": 0.47287900000000005,
    "result": {
      "reproduced": true,
      "summary": "The reported issue reproduces in Firefox Nightly. After loading https://www.mbusa.com/en/owners/manuals, the page renders the site header at the top and jumps straight to the \"Stay connected to all things Mercedes-Benz\" newsletter section and footer. The main content region \u2014 where the vehicle model catalog (the model picker grid users select to view their manuals) should appear \u2014 is empty. Inspection of the DOM shows the `<main>` element contains a `<css-css-oom-generic>` custom element host whose laid-out height is 0px and which contributes no visible text, so the catalog never becomes visible. A cookie/consent dialog (Usercentrics) overlays the page on first load but dismissing it does not bring the catalog back; the area between header and footer remains empty. This matches the report's expected vs. actual behavior.",
      "steps": "1. Launch Firefox with a clean profile (Firefox 154 Nightly or Release 151 \u2014 both reproduce per the report).\n2. In a new tab, navigate to https://www.mbusa.com/en/owners/manuals .\n3. Wait for the page to finish loading. Observe that the Mercedes-Benz site header (logo and Vehicles / Electric / Shopping / Owners / My Account / Find a Dealer / Search) appears at the top.\n4. If a Usercentrics \"Analytics and Advertising\" consent dialog appears at the bottom of the viewport, click \"Confirm\" to dismiss it (this step is not required to see the bug, but removes the overlay so the empty area is clearly visible).\n5. Scroll down from the top of the page and observe that immediately below the site header the page shows the \"Stay connected to all things Mercedes-Benz\" newsletter band followed by the footer (Vehicles / Shopping Tools / Electric / Owners Info / Discover Mercedes-Benz columns), with no vehicle model catalog rendered in between.\n6. (Optional verification) Open DevTools Console and run `document.querySelector('main').getBoundingClientRect()` \u2014 note the `<main>` element's height is ~80px and `document.querySelector('main').innerText.length` returns 0, confirming the catalog content area is empty.\n7. (Optional comparison) Load the same URL in Chrome with a clean profile and observe that the vehicle model catalog renders correctly in the main area."
    }
  },
  "actions": []
}

71 changes: 71 additions & 0 deletions agents/webcompat-triage/hackbot_agents/webcompat_triage/broker.py
Original file line number Diff line number Diff line change
@@ -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:<port>/mcp`.
The agent container itself binds no Bugzilla credentials.
"""

import logging
from contextlib import asynccontextmanager

import bugsy

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate this is not your fault, but I'm rather sad that bugbot is using bugsy, which is not maintained.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jgraham This was temporary :)

We have a draft PR to migrate to libmozdata: #6180

That said, even though libmozdata is maintained and widely used across our tools, I think we need a more modern library that offers:

  • Type safety with proper type hints/annotations (perhaps Pydantic)
  • Async support
  • A clean query builder API for constructing advanced queries
  • Sensible retry defaults for when Bugzilla is having bad time

It might be worth starting a conversation with you, me, and @dklawren to figure out the best path forward.

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("webcompat-triage-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()
Loading