Skip to content

Commit 32fabae

Browse files
committed
Add autowebebcompat agent
1 parent b2c767e commit 32fabae

18 files changed

Lines changed: 634 additions & 4 deletions

File tree

agents/autowebcompat/Dockerfile

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
FROM python:3.12 AS builder
2+
3+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
4+
5+
ENV UV_PROJECT_ENVIRONMENT=/opt/venv
6+
7+
WORKDIR /app
8+
9+
# Install external deps without building workspace members.
10+
RUN --mount=type=cache,target=/root/.cache/uv \
11+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
12+
--mount=type=bind,source=uv.lock,target=uv.lock \
13+
--mount=type=bind,source=VERSION,target=VERSION \
14+
uv sync --frozen --no-dev --no-install-workspace --package hackbot-agent-autowebcompat
15+
16+
RUN --mount=type=cache,target=/root/.cache/uv \
17+
--mount=type=bind,target=/app,rw \
18+
uv sync --locked --no-dev --no-editable --package hackbot-agent-autowebcompat
19+
20+
FROM python:3.12 AS base
21+
22+
COPY --from=builder /opt/venv /opt/venv
23+
WORKDIR /app
24+
25+
ENV PYTHONUNBUFFERED=1
26+
ENV PYTHONDONTWRITEBYTECODE=1
27+
ENV PATH="/opt/venv/bin:$PATH"
28+
29+
FROM base AS agent
30+
31+
# The Firefox DevTools MCP server is an npm package launched via `npx`, so the
32+
# agent image needs Node.js + npm (the python base ships neither). It also
33+
# needs a real Firefox binary to drive, plus the shared libraries Firefox
34+
# requires to run headless, and curl/xz for the install script below.
35+
RUN apt-get update \
36+
&& apt-get install -y --no-install-recommends \
37+
nodejs npm \
38+
ca-certificates curl xz-utils \
39+
libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 libxtst6 libxt6 \
40+
libasound2 libpci3 \
41+
&& rm -rf /var/lib/apt/lists/*
42+
43+
# Install Firefox Nightly: the script extracts to /opt/firefox; symlink it onto PATH
44+
# and point FIREFOX_PATH at it so the MCP server skips auto-detection.
45+
COPY agents/autowebcompat/scripts/install-firefox-nightly.sh /tmp/install-firefox-nightly.sh
46+
RUN bash /tmp/install-firefox-nightly.sh /opt/firefox \
47+
&& ln -s /opt/firefox/firefox /usr/local/bin/firefox \
48+
&& rm /tmp/install-firefox-nightly.sh
49+
50+
ENV FIREFOX_PATH=/opt/firefox/firefox
51+
52+
# hackbot.toml lives at the agent root (not inside the package), so copy it into
53+
# the working dir; the runtime discovers it there (cwd) at startup.
54+
COPY agents/autowebcompat/hackbot.toml /app/hackbot.toml
55+
56+
RUN useradd --create-home --shell /bin/bash agent \
57+
&& mkdir -p /workspace \
58+
&& chown agent:agent /workspace
59+
60+
USER agent
61+
62+
CMD ["python", "-m", "hackbot_agents.autowebcompat"]
63+
64+
FROM base AS broker
65+
66+
RUN useradd --create-home --shell /bin/bash broker
67+
68+
USER broker
69+
70+
EXPOSE 8765
71+
72+
CMD ["python", "-m", "hackbot_agents.autowebcompat.broker"]

agents/autowebcompat/compose.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
services:
2+
autowebcompat-broker:
3+
build:
4+
context: ../..
5+
dockerfile: agents/autowebcompat/Dockerfile
6+
target: broker
7+
environment:
8+
BUGZILLA_API_URL: ${BUGZILLA_API_URL}
9+
BUGZILLA_API_KEY: ${BUGZILLA_API_KEY}
10+
expose:
11+
- "8765"
12+
13+
autowebcompat-agent:
14+
build:
15+
context: ../..
16+
dockerfile: agents/autowebcompat/Dockerfile
17+
target: agent
18+
environment:
19+
- RUN_ID
20+
- BUG_DATA
21+
- BUG_ID
22+
- MODE=${MODE:-triage}
23+
- BUGZILLA_MCP_URL=http://autowebcompat-broker:8765/mcp
24+
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?error}
25+
# No uploader locally: summary/logs/attachments are written under
26+
# /artifacts/<run_id>, bind-mounted to the host's ~/hackbot/artifacts.
27+
- ARTIFACTS_DIR=/artifacts
28+
volumes:
29+
- ${HOME}/hackbot/artifacts:/artifacts
30+
depends_on:
31+
autowebcompat-broker:
32+
condition: service_started

agents/autowebcompat/hackbot.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# autowebcompat needs no platform prep: no [source] checkout, no [firefox] build.
2+
# Subject comes from the request (bug_data / bug_id); the DevTools MCP drives a
3+
# Firefox instance installed in the image.

agents/autowebcompat/hackbot_agents/autowebcompat/__init__.py

Whitespace-only changes.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from hackbot_runtime import HackbotContext, run_async
2+
from pydantic_settings import BaseSettings, SettingsConfigDict
3+
4+
from .agent import AutoWebcompatResult, run_autowebcompat
5+
6+
7+
class AgentInputs(BaseSettings):
8+
bugzilla_mcp_url: str
9+
bug_data: str | None = None
10+
bug_id: int | None = None
11+
mode: str = "triage"
12+
model: str | None = None
13+
max_turns: int | None = None
14+
effort: str | None = None
15+
# Path to the Firefox binary the DevTools MCP should drive. Set in the agent
16+
# image (FIREFOX_PATH=/opt/firefox/firefox)
17+
firefox_path: str | None = None
18+
19+
model_config = SettingsConfigDict(extra="ignore")
20+
21+
22+
async def main(ctx: HackbotContext) -> AutoWebcompatResult:
23+
inputs = AgentInputs()
24+
25+
return await run_autowebcompat(
26+
bugzilla_mcp_server={
27+
"type": "http",
28+
"url": inputs.bugzilla_mcp_url,
29+
},
30+
mode=inputs.mode,
31+
bug_data=inputs.bug_data,
32+
bug_id=inputs.bug_id,
33+
model=inputs.model,
34+
max_turns=inputs.max_turns,
35+
effort=inputs.effort,
36+
firefox_path=inputs.firefox_path,
37+
log=ctx.log_path,
38+
verbose=True,
39+
actions_recorder=ctx.actions,
40+
)
41+
42+
43+
if __name__ == "__main__":
44+
run_async(main)
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Firefox web-compatibility agent.
2+
3+
Drives an agent that reproduces a broken-site report in Firefox
4+
using the Firefox DevTools MCP. The bug is passed either inline as ``bug_data``
5+
text or a Bugzilla ``bug_id`` (read via Bugzilla broker).
6+
Bugzilla "writes" are recorded as actions into summary.json, never posted.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
from pathlib import Path
13+
14+
from claude_agent_sdk import (
15+
ClaudeAgentOptions,
16+
ClaudeSDKClient,
17+
McpServerConfig,
18+
ResultMessage,
19+
)
20+
from hackbot_runtime import ActionsRecorder, AgentError, HackbotAgentResult
21+
from hackbot_runtime.actions import ACTIONS_SERVER_NAME
22+
from hackbot_runtime.actions.claude_sdk import actions_server_for, actions_to_tool_names
23+
from hackbot_runtime.claude import Reporter
24+
25+
from .config import BUGZILLA_READ_TOOLS, DEVTOOLS_TOOLS, ENABLED_ACTION_TYPES
26+
from .devtools_mcp import build_devtools_server
27+
28+
HERE = Path(__file__).resolve().parent
29+
30+
# mode-specific prompt filename under prompts/. Every prompt is
31+
# system.md (shared rules) + the selected mode file, concatenated.
32+
PROMPTS = {
33+
"triage": "triage.md",
34+
"chrome-mask": "chrome_mask.md",
35+
}
36+
37+
38+
class AutoWebcompatResult(HackbotAgentResult):
39+
mode: str
40+
result: str | None = None
41+
42+
43+
def load_system_prompt(mode: str) -> str:
44+
try:
45+
filename = PROMPTS[mode]
46+
except KeyError as exc:
47+
raise AgentError(
48+
f"unknown mode {mode!r}; expected one of {sorted(PROMPTS)}"
49+
) from exc
50+
base = (HERE / "prompts" / "system.md").read_text()
51+
mode_specific = (HERE / "prompts" / filename).read_text()
52+
return f"{base.rstrip()}\n\n{mode_specific.lstrip()}"
53+
54+
55+
def build_user_prompt(bug_data: str | None, bug_id: int | None) -> str:
56+
if bug_data:
57+
return f"We've received this report:\n\n{bug_data}\n\n"
58+
if bug_id is not None:
59+
return (
60+
f"Fetch bug {bug_id} using Bugzilla MCP and identify the affected URL "
61+
"and broken behavior."
62+
)
63+
raise AgentError("neither bug_data nor bug_id was provided")
64+
65+
66+
async def run_autowebcompat(
67+
*,
68+
bugzilla_mcp_server: McpServerConfig,
69+
mode: str = "triage",
70+
bug_data: str | None = None,
71+
bug_id: int | None = None,
72+
model: str | None = None,
73+
max_turns: int | None = None,
74+
effort: str | None = None,
75+
firefox_path: str | None = None,
76+
verbose: bool = False,
77+
log: Path | None = None,
78+
actions_recorder: ActionsRecorder | None = None,
79+
) -> AutoWebcompatResult:
80+
"""Reproduce a web-compat issue and return the agent's findings.
81+
82+
Returns an :class:`AutoWebcompatResult` on success; raises
83+
:class:`AgentError` if the agent ends in an error.
84+
"""
85+
subject = bug_data if bug_data else f"bug {bug_id}"
86+
print(f"[autowebcompat] investigating {subject} (mode={mode})", file=sys.stderr)
87+
88+
devtools_server = build_devtools_server(
89+
firefox_path=Path(firefox_path) if firefox_path else None,
90+
headless=True,
91+
enable_script=True,
92+
)
93+
94+
# Action-recording MCP server (in-process). Standalone/script runs pass
95+
# actions_recorder=None and get a local recorder that copies attachments
96+
# under ./artifacts (no uploader).
97+
actions_recorder, actions_server = actions_server_for(
98+
actions_recorder, types=ENABLED_ACTION_TYPES
99+
)
100+
enabled_action_tools = actions_to_tool_names(ENABLED_ACTION_TYPES)
101+
102+
system_prompt = load_system_prompt(mode)
103+
104+
options = ClaudeAgentOptions(
105+
system_prompt=system_prompt,
106+
mcp_servers={
107+
"bugzilla": bugzilla_mcp_server,
108+
"firefox-devtools": devtools_server,
109+
ACTIONS_SERVER_NAME: actions_server,
110+
},
111+
permission_mode="bypassPermissions",
112+
allowed_tools=[
113+
"Read",
114+
"Grep",
115+
"Glob",
116+
"Bash",
117+
*BUGZILLA_READ_TOOLS,
118+
*DEVTOOLS_TOOLS,
119+
*enabled_action_tools,
120+
],
121+
disallowed_tools=["WebFetch", "WebSearch"],
122+
model=model,
123+
max_turns=max_turns,
124+
**({"effort": effort} if effort else {}),
125+
setting_sources=[],
126+
# DevTools snapshots/screenshots of complex pages serialize to JSON that
127+
# can exceed the SDK's default 1 MiB message buffer (the reader dies
128+
# fatally if it does). Raise it well above that ceiling.
129+
max_buffer_size=10 * 1024 * 1024,
130+
)
131+
132+
user_prompt = build_user_prompt(bug_data, bug_id)
133+
134+
result_msg: ResultMessage | None = None
135+
with Reporter(verbose=verbose, log_path=log) as reporter:
136+
reporter.header(subject)
137+
async with ClaudeSDKClient(options=options) as client:
138+
await client.query(user_prompt)
139+
async for msg in client.receive_response():
140+
reporter.message(msg)
141+
if isinstance(msg, ResultMessage):
142+
result_msg = msg
143+
144+
if result_msg is None:
145+
raise AgentError(f"{subject}: agent produced no result message")
146+
if result_msg.is_error:
147+
raise AgentError(
148+
f"{subject} investigation failed: {result_msg.result or result_msg.subtype}"
149+
)
150+
151+
return AutoWebcompatResult(
152+
mode=mode,
153+
result=result_msg.result,
154+
num_turns=result_msg.num_turns,
155+
total_cost_usd=result_msg.total_cost_usd,
156+
)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Bugzilla MCP broker.
2+
3+
Sidecar container that holds the Bugzilla API key and serves the
4+
bugzilla MCP tools over HTTP. The agent process (in a sibling container
5+
in the same Cloud Run Job task) reaches us at `127.0.0.1:<port>/mcp`.
6+
The agent container itself binds no Bugzilla credentials.
7+
"""
8+
9+
import logging
10+
from contextlib import asynccontextmanager
11+
12+
import bugsy
13+
import uvicorn
14+
from agent_tools import bugzilla
15+
from agent_tools.bugzilla import BugzillaContext
16+
from agent_tools.claude_sdk import build_sdk_server
17+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
18+
from pydantic_settings import BaseSettings, SettingsConfigDict
19+
from starlette.applications import Starlette
20+
from starlette.routing import Mount
21+
22+
log = logging.getLogger("autowebcompat-broker")
23+
24+
25+
class BrokerInputs(BaseSettings):
26+
bugzilla_api_url: str
27+
bugzilla_api_key: str
28+
host: str = "0.0.0.0"
29+
port: int = 8765
30+
31+
model_config = SettingsConfigDict(extra="ignore")
32+
33+
34+
def build_app(inputs: BrokerInputs) -> Starlette:
35+
client = bugsy.Bugsy(
36+
api_key=inputs.bugzilla_api_key, bugzilla_url=inputs.bugzilla_api_url
37+
)
38+
ctx = BugzillaContext(client=client)
39+
sdk_config = build_sdk_server("bugzilla", ctx, bugzilla.TOOLS)
40+
mcp_server = sdk_config["instance"]
41+
42+
manager = StreamableHTTPSessionManager(app=mcp_server, stateless=True)
43+
44+
@asynccontextmanager
45+
async def lifespan(app):
46+
async with manager.run():
47+
log.info(
48+
"bugzilla broker ready on %s:%d (read-only)",
49+
inputs.host,
50+
inputs.port,
51+
)
52+
yield
53+
54+
async def mcp_handler(scope, receive, send):
55+
await manager.handle_request(scope, receive, send)
56+
57+
return Starlette(routes=[Mount("/mcp", app=mcp_handler)], lifespan=lifespan)
58+
59+
60+
def main() -> None:
61+
logging.basicConfig(
62+
level=logging.INFO,
63+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
64+
)
65+
inputs = BrokerInputs()
66+
app = build_app(inputs)
67+
uvicorn.run(app, host=inputs.host, port=inputs.port, log_config=None)
68+
69+
70+
if __name__ == "__main__":
71+
main()

0 commit comments

Comments
 (0)