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
30 changes: 29 additions & 1 deletion agents/frontend-triage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,37 @@ Two things to know before acting on a plan:
the agent pinned a root cause in the code, never whether the fix works — it
cannot run anything. Read `high` as "trust the diagnosis, still review the
patch."
- **Line numbers are model-asserted and drift.** Trust the files, functions and
- **Line numbers are model-asserted.** The permalink pins the revision, so a
cited line can't drift out from under the link — but the number is still the
model's claim about where the problem is. Trust the files, functions and
selectors it names; confirm exact lines against the source.

Source files in the comment are inline Markdown links to **permalinked**
Searchfox URLs (`https://searchfox.org/firefox-main/rev/<sha>/<path>#<line>`), so
they keep pointing at the code the agent read.

The agent never handles the revision: it writes
`{{searchfox.permalink}}/<path>#<line>`, the same placeholder convention as
`{{actions.<ref>.url}}`, and an action hook on `bugzilla.add_comment` expands the
prefix as the comment is recorded — so what lands in `summary.json` for review is
already clickable. `agent.py` resolves the revision once per run with
`hackbot_runtime.searchfox.resolve_index_revision()`.

A path the agent names that isn't in the checkout is **not** linked: the hook
unwraps the Markdown link to its backticked label and logs a warning. Models do
occasionally invent a plausible path, and a permalink that 404s for whoever
clicks it is worse than plain text. The check is a heuristic — the checkout and
the pinned revision are hours apart, so a file added in between exists locally
but not on Searchfox — but it catches the common case.

That reads the permalink Searchfox publishes on a file page, rather than the
checkout's base commit, because Searchfox only serves `/rev/<sha>` for revisions
it has **indexed** — and its index trails the tip of `firefox.git` by hours (83
commits / ~20h when measured), so a link pinned to the checkout would 500 for
about a day, exactly the window in which someone reads the comment. If the
revision can't be resolved at all, the placeholder expands to Searchfox's
revision-agnostic `/source/` prefix: still a working link, just unpinned.

## Tuning

- **`rules/`** is the main behavior dial. `scoping.md` decides what gets skipped;
Expand Down
51 changes: 51 additions & 0 deletions agents/frontend-triage/hackbot_agents/frontend_triage/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@
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 hackbot_runtime.searchfox import (
PLACEHOLDER as SEARCHFOX_PLACEHOLDER,
)
from hackbot_runtime.searchfox import (
permalink_hook,
permalink_prefix,
resolve_index_revision,
)
from searchfox import AsyncSearchfoxClient

from .config import (
Expand Down Expand Up @@ -67,12 +75,40 @@ class FrontendTriageResult(HackbotAgentResult):
result: str | None = None


# How to cite a source file in the recorded comment. Injected into system.md
# rather than written there literally, because the template is run through
# str.format and the placeholder's braces would need doubling twice over in the
# prompt file; a substituted value passes through untouched.
_EXAMPLE_PATH = "browser/components/tabbrowser/content/tabgroup.js"
SEARCHFOX_LINKS_PROMPT = (
"Every source file you reference in your Bugzilla comment must be an inline "
f"Markdown link built from the `{SEARCHFOX_PLACEHOLDER}` placeholder, which "
"is expanded into a revision-pinned Searchfox URL when your comment is "
"recorded:\n\n"
f" [{_EXAMPLE_PATH}]({SEARCHFOX_PLACEHOLDER}/{_EXAMPLE_PATH})\n\n"
"- Write the placeholder **literally**. Do not put a revision, `tip` or "
"`HEAD` in it, and do not write a searchfox.org URL yourself — you do not "
"know which revision is being linked.\n"
"- After it, give the repo-relative path, plus a line anchor when you know "
"the line: `#1234`, or `#1234-1250` for a range. No `L` prefix.\n"
"- Use the path as the link text, without backticks — backticked text does "
"not render as a link.\n"
"- Leave the paths in the trailing ```json plan block as **bare paths** — "
"that block is parsed by a downstream tool, and a link there would corrupt "
"it.\n"
"- Only cite a path you have confirmed exists (Read/Glob it, or take it from "
"a Searchfox result). A path that is not in the checkout is stripped back to "
"plain text rather than linked, so guessing costs you the link."
)


def load_system_prompt(rules_dir: Path, extra: str) -> str:
tmpl = (HERE / "prompts" / "system.md").read_text()

return tmpl.format(
rules_dir=str(rules_dir.resolve()),
extra_instructions=extra or "(none)",
searchfox_links=SEARCHFOX_LINKS_PROMPT,
)


Expand Down Expand Up @@ -183,6 +219,21 @@ async def run_frontend_triage(
)
vcs_server = build_sdk_server("mozilla_vcs", MozillaVcsContext(), mozilla_vcs.TOOLS)

# Pin every source-file reference in the recorded comment to one revision.
# The agent writes a placeholder (see SEARCHFOX_LINKS_PROMPT) that this hook
# expands as the comment is recorded, so the agent never handles the SHA. An
# unresolvable revision degrades to revision-agnostic /source/ links.
searchfox_rev = await resolve_index_revision()
if not searchfox_rev:
print(
"[frontend_triage] no searchfox revision; linking tip-of-tree",
file=sys.stderr,
)
actions_recorder.add_hook(
"bugzilla.add_comment",
permalink_hook(permalink_prefix(searchfox_rev), source_repo.resolve()),
)

system_prompt = load_system_prompt(rules_dir, instructions)

options = ClaudeAgentOptions(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ Your working directory is the Firefox source repository. You have Read, Grep, Gl

**Always look for an existing test that exercises the affected area** (browser-chrome mochitests usually live in a component's `tests/browser/` directory; also check `tests/`/`test/` and xpcshell tests). Record what you find in the `relevant_tests` field — it is the downstream executor's verification anchor. If you searched and there is no covering test, say so (empty `relevant_tests`).

When you reference a cause or a fix target, cite concrete paths (and ideally functions/selectors), e.g. `browser/components/tabbrowser/content/tabgroup.js`.
When you reference a cause or a fix target, cite concrete paths (and ideally functions/selectors), e.g. `browser/components/tabbrowser/content/tabgroup.js`. In your Bugzilla comment those paths must be Searchfox permalinks — see **Linking source files** below.

# Linking source files

{searchfox_links}

# Code-search & history tools

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ ruleset does not apply — note that and stop.
## Comment

Record a single brief comment (a few sentences) with: the suspected root cause,
the target file(s), and the proposed approach. Cite concrete paths. Do not
restate the whole bug. Do not claim the fix is verified — you did not run it.
the target file(s), and the proposed approach. Cite concrete paths, each as an
inline Markdown link to its Searchfox permalink (with a line anchor where you
know the line) per the **Linking source files** section of the system prompt. Do
not restate the whole bug. Do not claim the fix is verified — you did not run it.

## Confidence and field changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ def _request(method: str, path: str, json_body: dict[str, Any]) -> dict[str, Any
return response.json()


def _comment_body(params: dict[str, Any]) -> dict[str, Any]:
"""Build the ``comment`` object for a PUT /bug/{id}.

Agents author their comments in Markdown (paths as Searchfox permalinks,
the italic automated-analysis footer), so ``is_markdown`` is always set --
without it Bugzilla falls back to the API user's preference and renders the
markup literally.
"""
return {
"body": params["text"],
"is_private": bool(params.get("is_private", False)),
"is_markdown": True,
}


class UpdateBugHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
bug_id = params["bug_id"]
Expand All @@ -75,12 +90,7 @@ async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult
class AddCommentHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
bug_id = params["bug_id"]
body = {
"comment": {
"body": params["text"],
"is_private": params.get("is_private", False),
}
}
body = {"comment": _comment_body(params)}
try:
_request("PUT", f"bug/{bug_id}", body)
except Exception as exc:
Expand Down Expand Up @@ -207,10 +217,7 @@ def merge_resolved(entries: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
if action_type == "bugzilla.update_bug":
changes.update(params.get("changes", {}))
elif action_type == "bugzilla.add_comment":
comment = {
"body": params["text"],
"is_private": bool(params.get("is_private", False)),
}
comment = _comment_body(params)

combined: dict[str, Any] = {"bug_id": bug_id, "changes": changes}
if comment is not None:
Expand Down
184 changes: 184 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/searchfox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Expand Searchfox permalink placeholders in recorded Bugzilla comments.

A permalink pins a file to a revision, so a source reference in a triage comment
keeps pointing at the code that was actually reasoned about even after the tree
moves on. Rather than have the agent write (and risk mangling) a 40-character
SHA, it writes a placeholder and this module substitutes the prefix:

[browser/base/content/browser.js#412]({{searchfox.permalink}}/browser/base/content/browser.js#412)

Mirrors the ``{{actions.<ref>.url}}`` convention, but expands at *record* time
rather than apply time: unlike a prior action's result, the revision is known
before the agent runs, and expanding early means the comment awaiting review in
``summary.json`` shows the real URLs a human can click.
"""

from __future__ import annotations

import logging
import re
from collections.abc import Callable
from pathlib import Path

log = logging.getLogger(__name__)

# Searchfox renamed the Firefox tree from "mozilla-central" to "firefox-main"
# when it moved to git; the old name only 301-redirects.
TREE = "firefox-main"

_BASE_URL = f"https://searchfox.org/{TREE}"
_USER_AGENT = "bugbug-hackbot-runtime"

PLACEHOLDER = "{{searchfox.permalink}}"
# Tolerate inner whitespace — the agent writes this by hand.
_PLACEHOLDER = r"\{\{\s*searchfox\.permalink\s*\}\}"
# The placeholder as the whole URL of a Markdown link, so an unresolvable target
# can be degraded by unwrapping the link rather than just dropping its href.
_LINKED_PLACEHOLDER_RE = re.compile(
rf"\[(?P<label>[^\]\n]*)\]\(\s*{_PLACEHOLDER}(?P<target>[^)\s]*)\s*\)"
)
# The placeholder anywhere else, with whatever path the agent appended to it.
_BARE_PLACEHOLDER_RE = re.compile(rf"{_PLACEHOLDER}(?P<target>\S*)")

# Every Searchfox *file* page carries the permalink its own UI would produce, as
# the href of the "Create a revision-specific link" panel item — which is the
# revision the index was built from. Any stable file works as the probe; a
# top-level moz.build is about as permanent as the tree gets.
_PROBE_PATH = "toolkit/moz.build"
_PROBE_URL = f"{_BASE_URL}/source/{_PROBE_PATH}"
_PANEL_PERMALINK_RE = re.compile(
rf'<a id="panel-permalink" href="/{TREE}/rev/(?P<rev>[0-9a-f]{{40}})/'
)


def permalink_prefix(rev: str | None) -> str:
"""The URL prefix :data:`PLACEHOLDER` expands to.

With no revision this degrades to Searchfox's revision-agnostic ``/source/``
prefix: still a working link to the right file, just following the tip
instead of pinned. That beats leaving an unexpanded placeholder (or a bare
path) in a comment a human is about to read.
"""
return f"{_BASE_URL}/rev/{rev}" if rev else f"{_BASE_URL}/source"


async def resolve_index_revision(*, client=None, timeout: float = 10.0) -> str | None:
"""Return the git SHA Searchfox's index is pinned to, or ``None``.

Reads the permalink Searchfox publishes on a file page, so the revision is
by construction one Searchfox can serve.

Note this is deliberately *not* the checked-out repo's base commit. Searchfox
only serves ``/rev/<sha>`` for revisions it has indexed, and its index runs
behind the tip of ``firefox.git`` by hours (83 commits / ~20h when measured);
the checkout's HEAD therefore 500s for about a day after a run — exactly the
window in which someone reads the triage comment.

Never raises: a caller that cannot get a revision should fall back to a
revision-agnostic link rather than fail its run. ``client`` accepts an
httpx-like async client for testing; when omitted, ``httpx`` is used.
"""
try:
if client is None:
import httpx

async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True
) as owned:
resp = await owned.get(_PROBE_URL, headers={"User-Agent": _USER_AGENT})
else:
resp = await client.get(_PROBE_URL, headers={"User-Agent": _USER_AGENT})
if resp.status_code != 200:
log.warning("searchfox %s returned HTTP %s", _PROBE_PATH, resp.status_code)
return None
match = _PANEL_PERMALINK_RE.search(resp.text)
except Exception as e:
log.warning("could not resolve the searchfox index revision: %s", e)
return None

if match is None:
log.warning("no permalink found on the searchfox %s page", _PROBE_PATH)
return None
return match.group("rev")


def _join(prefix: str, target: str) -> str:
if not target:
return prefix.rstrip("/")
return f"{prefix.rstrip('/')}/{target.lstrip('/')}"


def _target_path(target: str) -> str:
"""The repo-relative path in a placeholder target, minus any ``#`` anchor."""
return target.lstrip("/").split("#", 1)[0]


def expand_placeholders(
text: str, prefix: str, *, repo_root: Path | None = None
) -> str:
"""Replace every :data:`PLACEHOLDER` in ``text`` with ``prefix``.

With ``repo_root``, a placeholder whose path does not exist in the checkout
is *not* linked: a Markdown link is unwrapped to its backticked label, and a
bare placeholder degrades to its backticked path. Models do occasionally
name a file that isn't there, and a plausible-looking permalink to a
nonexistent path is worse than plain text — it 404s for whoever clicks it.

The check is a heuristic, not a proof: the checkout and the revision the
links are pinned to are hours apart, so a file added in between exists
locally but not on Searchfox (and one deleted in between, vice versa). It
catches the common case, which is an invented path.
"""

def _linkable(target: str) -> bool:
if repo_root is None:
return True
path = _target_path(target)
# Nothing to verify (bare placeholder, or anchor only) — let it expand.
return not path or (repo_root / path).is_file()

def _link(match: re.Match[str]) -> str:
label, target = match.group("label"), match.group("target")
if _linkable(target):
return f"[{label}]({_join(prefix, target)})"
log.warning(
"not linking %r: no such file in the checkout (left as plain text)",
_target_path(target),
)
return f"`{label}`"

def _bare(match: re.Match[str]) -> str:
target = match.group("target")
if _linkable(target):
return _join(prefix, target)
log.warning(
"not linking %r: no such file in the checkout (left as plain text)",
_target_path(target),
)
return f"`{target.lstrip('/')}`"

# Links first, so their placeholders are consumed before the bare pass.
text = _LINKED_PLACEHOLDER_RE.sub(_link, text)
return _BARE_PLACEHOLDER_RE.sub(_bare, text)


def permalink_hook(
prefix: str, repo_root: Path | None = None
) -> Callable[[dict], None]:
"""An action hook that expands permalink placeholders in a comment's text.

Register for ``bugzilla.add_comment``; the recorded comment then carries real
URLs instead of placeholders. ``repo_root`` is the agent's source checkout,
used to skip linking paths that do not exist (see
:func:`expand_placeholders`).
"""

def hook(action: dict) -> None:
params = action.get("params")
if not isinstance(params, dict):
return
text = params.get("text")
if isinstance(text, str):
params["text"] = expand_placeholders(text, prefix, repo_root=repo_root)

return hook
Loading