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
82 changes: 53 additions & 29 deletions agents/frontend-triage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,26 @@ human (or a downstream execution agent) takes it from there.
## What it triages

**Defects in user-facing Firefox** — the kind documented with a screenshot, steps
to reproduce, or a log rather than a stack trace:

- **Desktop frontend**, under `Firefox`: Tabbed Browser (incl. Split View and Tab
Groups), New Tab Page, Address Bar, Menus, Toolbars and Customization, Sidebar,
Site Permissions, Theme.
- **Firefox for Android**: History. Kotlin under `mobile/android/fenix/`.
- **Install and update**: `Firefox :: Installer` (NSIS) and
`Toolkit :: Application Update` (`.sys.mjs`, IDL, C++).
to reproduce, or a log rather than a stack trace. `scoping.md` is what decides
scope, and it is broad: any user-facing Firefox defect qualifies.

What is _routed_ is narrower. `TRIAGE_SCOPE` in `config.py` lists the components
bugs normally arrive from, one entry each, carrying the Slack channel and the area
whose code layout `prompts/system.md` describes. A bug handed to the agent by hand
in some other component — `Firefox :: Menus`, say — is triaged the same way and
reports to nobody. The areas, which are also how the rendered scope list is
grouped:

- **Desktop frontend**, under `Firefox`. JS/JSM modules, CSS, XUL/HTML.
- **Site permissions**, also desktop, but split across the doorhanger, the state,
and a C++ store outside `browser/`.
- **IP Protection**, the built-in VPN. Panel UI in
`browser/components/ipprotection/`, the proxy and entitlement state machines in
`toolkit/components/ipprotection/`.
- **Firefox for Android**. Kotlin under `mobile/android/fenix/` and
`mobile/android/android-components/`.
- **Application updater** — `Toolkit :: Application Update` (`.sys.mjs`, IDL, C++).
- **Windows installer** — `Firefox :: Installer` (NSIS).

Install and update bugs are the odd ones out: they arrive as a failure with an
error code and an `update.log` or installer log, usually with no steps to
Expand All @@ -31,6 +43,11 @@ framing reads as a reason to skip them. `severity-assessment.md` starts them at
S2 rather than the S3 a papercut would get, since a user who cannot update is
left on an unpatched build with no in-product workaround.

IP Protection has the same S2 floor, for the same reason: turning the VPN off is
not a workaround for it not working. It carries one extra instruction, because the
distinction does not survive a bug report — state merely _displayed_ wrong is a UI
bug, while state actually wrong means traffic is unproxied and belongs above S2.

Poor fits: crashes, hangs, assertions and sanitizer reports (those belong to
[`bug-fix`](../bug-fix/)) — note that "the installer failed" is not a crash
report — anything outside user-facing Firefox, and bugs whose fix can only be
Expand Down Expand Up @@ -173,22 +190,27 @@ The audience is the team whose bug was just written to by nobody, so only an
auto-applied run notifies. A medium or low result wrote nothing to Bugzilla and
stays silent, even if someone applies it by hand later.

Routing is `SLACK_CHANNELS` in `config.py`, keyed by `"<Product> :: <Component>"`:

| Product :: Component | Channel |
| -------------------------------- | ------------------------------- |
| `Firefox :: New Tab Page` | `#hnt-dev-triage` |
| `Firefox for Android :: History` | `#android-core-dev` |
| `Toolkit :: Application Update` | `#installer-updater-bug-triage` |
| `Firefox :: Installer` | `#installer-updater-bug-triage` |
| `Firefox :: Site Permissions` | `#privacy-team-automation` |

Two components may share a channel, as the installer and the updater do; the key is
the component, not the team. A component that is not listed notifies nobody; there is
deliberately no default channel, since posting one team's triage into another team's
channel is worse than silence. Product and component come from the agent's
`product`/`component` plan fields, because nothing else carries them out of a run
whose only input is a bug id, so a garbled value matches no team and sends nothing.
Routing is the `channel` on each `TRIAGE_SCOPE` entry in `config.py`, looked up by
`"<Product> :: <Component>"` through the derived `SLACK_CHANNELS` — so
`ScopedComponent("Firefox", "New Tab Page", "Desktop frontend", "#hnt-dev-triage")`
sends a New Tab Page run to `#hnt-dev-triage`.

Four things about that which are not obvious from reading the registry:

- **The key is the component, not the team**, so two components may share a channel, as
the installer and the updater do, without either knowing about the other.
- **There is deliberately no default channel**, since posting one team's triage into
another team's channel is worse than silence.
- **`TRIAGE_SCOPE` is narrower than what the agent will triage.** It is the routing
table, and it should stay in step with bugbot's `TRIAGED_COMPONENTS`, which decides
what arrives automatically. `scoping.md` puts _any_ user-facing Firefox defect in
scope, so a bug handed to the agent by hand in some other component is triaged
normally and reports to nobody. The system prompt says so explicitly, because a list
of components read as exhaustive is how an in-scope bug gets declared out of scope.
- **Product and component come from the agent's `product`/`component` plan fields**,
because nothing else carries them out of a run whose only input is a bug id. A garbled
value matches no team and sends nothing, which is why the system prompt asks for them
verbatim even for components the scope list does not name.

`notify.py` builds and records the message; the wording is code, not a model turn,
so `slack.post_message` is _not_ in `ENABLED_ACTION_TYPES` and the agent is never
Expand All @@ -205,12 +227,14 @@ notifies. The run page shows the failed action.
`rules/` and `prompts/` both live under `hackbot_agents/frontend_triage/`.

- **`rules/`** is the main behavior dial. `scoping.md` decides what gets skipped;
`frontend-triage.md` sets in-scope components, comment content, and the
confidence thresholds for recording an action. The agent globs the directory
and reads only what it judges relevant, so new `.md` files extend it — see
`rules/README.md` for how to author one.
`frontend-triage.md` sets comment content and the confidence thresholds for
recording an action. The agent globs the directory and reads only what it judges
relevant, so new `.md` files extend it — see `rules/README.md` for how to author
one. Neither file lists components; `TRIAGE_SCOPE` in `config.py` does.
- **`prompts/system.md`** holds the standing instructions: output format, the
read-only mandate, and when to reach for Searchfox versus reading a file.
read-only mandate, when to reach for Searchfox versus reading a file, and the
per-area code layout. A component in a new area needs a **Source repository**
bullet here, and `tests/test_plan.py` fails until it has one.
- **Cost** scales with tool use, not just turns — Searchfox results are
token-heavy, so narrowing queries (`path_filter`, a modest `limit`) matters
more than `MAX_TURNS` when batching.
Expand Down
41 changes: 41 additions & 0 deletions agents/frontend-triage/hackbot_agents/frontend_triage/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
ENABLED_ACTION_TYPES,
MOZILLA_VCS_TOOLS,
SEARCHFOX_TOOLS,
TRIAGE_SCOPE,
ScopedComponent,
)
from .hooks import add_comment_hook, update_bug_hook

Expand Down Expand Up @@ -140,13 +142,52 @@ class FrontendTriageResult(HackbotAgentResult):
)


def render_scope(scope: tuple[ScopedComponent, ...] = TRIAGE_SCOPE) -> str:
"""Render `config.TRIAGE_SCOPE` as the prompt's component list, grouped by area.

Generated rather than written into the prompt so that the component list has one
home. The per-area guidance under `Source repository` stays hand-authored: it is
prose about a codebase, and only the enumeration is mechanical.

Takes the registry as an argument so a test can assert the grouping against a fixed
input rather than against whatever the real scope happens to be today.
"""
by_area: dict[str, list[str]] = {}
for entry in scope:
by_area.setdefault(entry.area, []).append(entry.key)

lines = [f"- **{area}** — {', '.join(keys)}." for area, keys in by_area.items()]

return "\n".join(
lines
+ [
"",
# Two failure modes to close off, in order of how much they cost. Reading the
# list as exhaustive gets an in-scope bug declared out of scope, which is the
# `ecea6ca6` mistake. Reading it as a vocabulary gets a component "tidied" to
# match, and since the component is also the routing key, `notify.py` then
# silently tells nobody.
"**This list is not the limit of what you triage.** It is where bugs "
"normally come from, and which team each one reports to. `scoping.md` is "
"what decides scope: any user-facing Firefox defect is in scope, including "
"in a component not named above — triage it normally rather than calling it "
"out of scope for being absent here.",
"",
"It is also **not** a vocabulary for the `product` and `component` fields "
"of your plan. Copy those from Bugzilla verbatim, even when they are not "
"listed above.",
]
)


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,
triaged_components=render_scope(),
)


Expand Down
86 changes: 75 additions & 11 deletions agents/frontend-triage/hackbot_agents/frontend_triage/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import NamedTuple

# Bugzilla MCP tool names as exposed to the agent (mcp__<server>__<tool>).
BUGZILLA_READ_TOOLS = [
"mcp__bugzilla__search_bugs",
Expand Down Expand Up @@ -42,24 +44,86 @@
"bugzilla.update_bug",
]

# Where an auto-applied run reports itself, by `"<Product> :: <Component>"`. A channel
# belongs to the team that owns the component, so the routing does too: a component
# that is not listed sends nothing, since posting one team's triage into another team's
# channel is worse than silence. There is deliberately no default channel.

class ScopedComponent(NamedTuple):
"""A Bugzilla component sent here for triage, and where a finished run reports it."""

product: str
component: str
# Which `Source repository` bullet in prompts/system.md describes this component's
# code. `tests/test_plan.py` asserts every area named here has one, so a new area
# cannot be added without the guidance that makes it triageable.
area: str
# Required, because an entry without one would be a component getting unattended
# triage with nobody told -- which is what `channel_for` failing closed produces,
# and not something to be able to express by accident.
channel: str

@property
def key(self) -> str:
return f"{self.product} :: {self.component}"


# The components that are sent here for triage, and the channel that owns each. The
# single source of truth for both: `SLACK_CHANNELS` below is derived from it, and
# `render_scope` in agent.py renders it into the system prompt, so adding a component is
# one entry here rather than the same name written into three prose lists and a test.
#
# This is narrower than what the agent will triage. `rules/scoping.md` puts any
# user-facing Firefox defect in scope, and a bug handed to the agent by hand is triaged
# on that rule whether or not its component is named here -- it just reports to nobody.
# What this tuple decides is routing, and it should stay in step with bugbot's
# `TRIAGED_COMPONENTS`, which decides what arrives automatically.
#
# A channel belongs to the team that owns the component, so the routing does too: a
# component that is not listed sends nothing, since posting one team's triage into
# another team's channel is worse than silence. There is deliberately no default channel.
#
# `slack.post_message` is left out of `ENABLED_ACTION_TYPES` on purpose. The message is
# code (see notify.py), not a model turn, so it goes through the recorder directly and
# the agent is never given the tool — it has no say in what is said or where.
SLACK_CHANNELS = {
"Firefox :: New Tab Page": "#hnt-dev-triage",
"Firefox for Android :: History": "#android-core-dev",
#
# Ordered by area, grouped by first appearance — `render_scope` preserves that order, so
# this is also the order the model reads. There is no separate list of areas to keep in
# sync with this one.
TRIAGE_SCOPE = (
ScopedComponent("Firefox", "New Tab Page", "Desktop frontend", "#hnt-dev-triage"),
ScopedComponent(
"Firefox", "Site Permissions", "Site permissions", "#privacy-team-automation"
),
ScopedComponent("Firefox", "Sharing", "Sharing", "#content-sharing-automation"),
ScopedComponent(
"Firefox",
"IP Protection",
"IP Protection",
"#team-eng-ip-protection-triage",
),
ScopedComponent(
"Firefox for Android", "History", "Firefox for Android", "#android-core-dev"
),
ScopedComponent(
"Firefox for Android", "Toolbar", "Firefox for Android", "#android-core-dev"
),
ScopedComponent(
"Firefox for Android", "Homepage", "Firefox for Android", "#android-core-dev"
),
# The installer and the updater are triaged by the same team, so two components
# share a channel. Keying by product-and-component rather than by channel is what
# lets them, without either one having to know about the other.
"Toolkit :: Application Update": "#installer-updater-bug-triage",
"Firefox :: Installer": "#installer-updater-bug-triage",
"Firefox :: Site Permissions": "#privacy-team-automation",
}
ScopedComponent(
"Toolkit",
"Application Update",
"Application updater",
"#installer-updater-bug-triage",
),
ScopedComponent(
"Firefox", "Installer", "Windows installer", "#installer-updater-bug-triage"
),
)

# Where an auto-applied run reports itself, by `"<Product> :: <Component>"`. Derived, so
# that `notify.py` keeps one flat mapping to look up.
SLACK_CHANNELS = {c.key: c.channel for c in TRIAGE_SCOPE}

# What a `bugzilla.update_bug` from this agent may touch. Enforced at record time
# by `hooks.update_bug_hook`, so an out-of-bounds change is refused while the agent
Expand Down
Loading