diff --git a/agents/sentry-triage/Dockerfile b/agents/sentry-triage/Dockerfile new file mode 100644 index 0000000000..36caf73ac4 --- /dev/null +++ b/agents/sentry-triage/Dockerfile @@ -0,0 +1,51 @@ +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-sentry-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-sentry-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 + +# 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/sentry-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.sentry_triage"] + +FROM base AS broker + +RUN useradd --create-home --shell /bin/bash broker + +USER broker + +EXPOSE 8765 + +CMD ["python", "-m", "hackbot_agents.sentry_triage.broker"] diff --git a/agents/sentry-triage/compose.yml b/agents/sentry-triage/compose.yml new file mode 100644 index 0000000000..e0303982b1 --- /dev/null +++ b/agents/sentry-triage/compose.yml @@ -0,0 +1,36 @@ +services: + sentry-triage-broker: + build: + context: ../.. + dockerfile: agents/sentry-triage/Dockerfile + target: broker + environment: + - SENTRY_API_KEY=${SENTRY_API_KEY:?error} + - SENTRY_API_URL_BASE=${SENTRY_API_URL_BASE:?error} + - SENTRY_ORG_NAME=${SENTRY_ORG_NAME:?error} + expose: + - "8765" + + sentry-triage-agent: + build: + context: ../.. + dockerfile: agents/sentry-triage/Dockerfile + target: agent + environment: + # No uploader locally: summary/logs/attachments are written under + # /artifacts/, bind-mounted to the host's ~/hackbot/artifacts so + # compose and direct runs land in the same user-scoped location. + - ARTIFACTS_DIR=/artifacts + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?error} + - RUN_ID + - SENTRY_ALERT_URL=${SENTRY_ALERT_URL:?error} + - SENTRY_MCP_URL=http://sentry-triage-broker:8765/mcp + volumes: + - workspace:/workspace + - ${HOME}/hackbot/artifacts:/artifacts + depends_on: + sentry-triage-broker: + condition: service_started + +volumes: + workspace: diff --git a/agents/sentry-triage/hackbot.toml b/agents/sentry-triage/hackbot.toml new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/agents/sentry-triage/hackbot.toml @@ -0,0 +1 @@ + diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/__init__.py b/agents/sentry-triage/hackbot_agents/sentry_triage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/__main__.py b/agents/sentry-triage/hackbot_agents/sentry_triage/__main__.py new file mode 100644 index 0000000000..0c1b2c01cd --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/__main__.py @@ -0,0 +1,36 @@ +from hackbot_runtime import HackbotContext, run_async +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .agent import SentryTriageResult, run_alert_triage + + +class AgentInputs(BaseSettings): + sentry_alert_url: str + sentry_mcp_url: str + model: str | None = None + max_turns: int | None = None + effort: str | None = None + + model_config = SettingsConfigDict(extra="ignore") + + +async def main(ctx: HackbotContext) -> SentryTriageResult: + inputs = AgentInputs() + + return await run_alert_triage( + sentry_mcp_server={ + "type": "http", + "url": inputs.sentry_mcp_url, + }, + sentry_alert_url=inputs.sentry_alert_url, + model=inputs.model, + max_turns=inputs.max_turns, + effort=inputs.effort, + log=ctx.log_path, + verbose=True, + actions_recorder=ctx.actions, + ) + + +if __name__ == "__main__": + run_async(main) diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/agent.py b/agents/sentry-triage/hackbot_agents/sentry_triage/agent.py new file mode 100644 index 0000000000..92cc033bc6 --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/agent.py @@ -0,0 +1,169 @@ +"""Sentry triage tool -- a Sentry triage agent. + +Orchestrates a Claude agent that triages Sentry alerts according to rulesets +in the rules/ directory. The agent has access to the Mozilla Sentry instance +via an out-of-process MCP broker (HTTP transport) that holds the Sentry +API token — the agent process itself never sees it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from claude_agent_sdk import ( + AgentDefinition, + ClaudeAgentOptions, + ClaudeSDKClient, + McpServerConfig, + ResultMessage, +) +from hackbot_runtime import ActionsRecorder, AgentError, HackbotAgentResult +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 .config import ( + SENTRY_READ_TOOLS, + TRIAGE_ACTIONS, +) + +HERE = Path(__file__).resolve().parent +PROMPTS = HERE / "prompts" + + +class SentryTriageResult(HackbotAgentResult): + result: str | None = None + + +def render_prompt(name: str, **fields: object) -> str: + """Render a prompt template from ``prompts/`` via ``str.format``. + + Prompt text lives in ``prompts/*.md`` rather than inline in Python, so it + stays readable and editable. Substituted values are inserted verbatim + (``str.format`` does not re-scan them), so an untrusted ``comment`` cannot + break out of its ``{comment}`` placeholder. + """ + return (PROMPTS / name).read_text().format(**fields) + + +def make_alert_investigator() -> AgentDefinition: + """Create a single generic investigator subagent definition.""" + return AgentDefinition( + description=( + "Focused investigator for triaging what issues resulted in a " + "Sentry alert being triggered. The main agent writes your " + "complete instructions at spawn time — follow them precisely and " + "return only what was asked for." + ), + prompt=( + "You are a focused investigator subagent. You will be given a " + "self-contained task by the triage agent. Complete it and return " + "a concise answer. Do not make any modifications — you have " + "read-only access. Do not speculate beyond what you can verify." + ), + tools=[ + *SENTRY_READ_TOOLS, + ], + model="inherit", + ) + + +def parse_sentry_alert_url_for_issue_and_event_ids(sentry_alert_url: str) -> tuple[str, str]: + """Parses a Sentry alert URL for the issue and event ids.""" + # TODO: parse the URL for issue_id and event_id + # e.g. https://mozilla.sentry.io/issues/7325411784/events/16f37d8003bb42f4abff707fb57bfca0/?alert=5775&detection_type=static¬ification_uuid=a672e1f5-d561-4f2d-92f4-441dd86e8fd2&openPeriod=589904722&project=4510958917713920&referrer=metric_alert_slack&statsPeriod=12d + + # return issue_id, event_id + return "7325411784", "16f37d8003bb42f4abff707fb57bfca0" + + +async def run_alert_triage( + *, + sentry_mcp_server: McpServerConfig, + sentry_alert_url: str, + rules_dir: Path | None = None, + model: str | None = None, + max_turns: int | None = None, + effort: str | None = None, + verbose: bool = False, + log: Path | None = None, + actions_recorder: ActionsRecorder | None = None +) -> SentryTriageResult: + """Triage a single Sentry alert with a claude-agent-sdk agent. + + Returns a :class:`SentryTriageResult` on success; raises :class:`AgentError` if the + agent ends in an error. + """ + + # load custom claude rules + if rules_dir is None: + rules_dir = HERE / "rules" + + print(f"[sentry_triage] triaging sentry alert at {sentry_alert_url}", file=sys.stderr) + + # Action-recording MCP server (in-process). Standalone/script runs pass + # actions_recorder=None and get a local recorder that copies attachments + # under ./artifacts (no uploader). + actions_recorder, actions_server = actions_server_for( + actions_recorder, types=TRIAGE_ACTIONS + ) + + sentry_issue_id, sentry_event_id = parse_sentry_alert_url_for_issue_and_event_ids(sentry_alert_url) + + enabled_action_tools = actions_to_tool_names(TRIAGE_ACTIONS) + + system_prompt = render_prompt("system.md", rules_dir=str(rules_dir.resolve())) + user_prompt = render_prompt("triage.md", issue_id=sentry_issue_id, event_id=sentry_event_id, rules_path=rules_dir) + + options = ClaudeAgentOptions( + add_dirs=[str(rules_dir.resolve())], + system_prompt=system_prompt, + mcp_servers={ + "sentry": sentry_mcp_server, + ACTIONS_SERVER_NAME: actions_server, + }, + agents={"investigator": make_alert_investigator()}, + permission_mode="bypassPermissions", + allowed_tools=[ + "Read", + "Grep", + "Glob", + "Bash", + "Task", + *enabled_action_tools, + *SENTRY_READ_TOOLS, + ], + model=model, + max_turns=max_turns, + **({"effort": effort} if effort else {}), + setting_sources=[], + ) + + result_msg: ResultMessage | None = None + + with Reporter(verbose=verbose, log_path=log) as reporter: + reporter.header(f"sentry alert url {sentry_alert_url}") + + 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"sentry alert url {sentry_alert_url}: agent produced no result message") + + if result_msg.is_error: + raise AgentError( + f"sentry alert url {sentry_alert_url} triage failed: {result_msg.result or result_msg.subtype}" + ) + + return SentryTriageResult( + result=result_msg.result, + num_turns=result_msg.num_turns, + total_cost_usd=result_msg.total_cost_usd, + ) diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/broker.py b/agents/sentry-triage/hackbot_agents/sentry_triage/broker.py new file mode 100644 index 0000000000..7987f6df32 --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/broker.py @@ -0,0 +1,82 @@ +"""Sentry MCP broker. + +Sidecar container that holds privileged API keys and serves Sentry MCP tools +over HTTP to the agent process (a sibling container in the same Cloud Run Job +task), which reaches us at `127.0.0.1:`. The agent container itself binds +no credentials: + +- Sentry: the `Sentry` MCP tools over `/mcp` (read-only, live during the run). +""" + +import httpx +import logging +from contextlib import asynccontextmanager + +import uvicorn +from agent_tools import sentry +from agent_tools.claude_sdk import build_sdk_server +from agent_tools.sentry import SentryContext +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("sentry-broker") + + +class BrokerInputs(BaseSettings): + host: str = "0.0.0.0" + port: int = 8765 + sentry_api_key: str + sentry_api_url_base: str + sentry_org_name: str + + model_config = SettingsConfigDict(extra="ignore") + + +def build_app(inputs: BrokerInputs) -> Starlette: + ctx = SentryContext( + api_token=inputs.sentry_api_key, + api_url_base=inputs.sentry_api_url_base, + client=httpx.AsyncClient(), + org_name=inputs.sentry_org_name, + ) + + sdk_config = build_sdk_server("sentry", ctx, sentry.TOOLS) + mcp_server = sdk_config["instance"] + + manager = StreamableHTTPSessionManager(app=mcp_server, stateless=True) + + @asynccontextmanager + async def lifespan(app): + async with manager.run(): + log.info( + "broker ready on %s:%d (sentry 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() diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/config.py b/agents/sentry-triage/hackbot_agents/sentry_triage/config.py new file mode 100644 index 0000000000..f6d0c78c6b --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/config.py @@ -0,0 +1,9 @@ +# Sentry MCP tool names as exposed to the agent (mcp____). +SENTRY_READ_TOOLS = [ + "mcp__sentry__get_issue_event", +] + +# Action types that the agent may record during triage/fix runs. +TRIAGE_ACTIONS = [ + "slack.post_findings", +] diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/prompts/system.md b/agents/sentry-triage/hackbot_agents/sentry_triage/prompts/system.md new file mode 100644 index 0000000000..f8fc644884 --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/prompts/system.md @@ -0,0 +1,10 @@ +You are Hackbot, an autonomous alert triage agent operating against a Sentry alert. + +# Your job + +You are given a Sentry issue ID and event ID to triage and you must: + +1. **Fetch** the Sentry alert using the `Sentry` MCP tools. +2. **Read the relevant triage rules** from `{rules_dir}` - + +TODO... diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/prompts/triage.md b/agents/sentry-triage/hackbot_agents/sentry_triage/prompts/triage.md new file mode 100644 index 0000000000..92fccc00eb --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/prompts/triage.md @@ -0,0 +1,6 @@ +Sentry issue to work on: {issue_id} +Sentry event to work on: {event_id} + +Task: Triage the alert and summarize contributing issues. + +The rules in {rules_path} are available if the task calls for them, but the task above is your primary directive and overrides the default triage workflow. diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/rules/README.md b/agents/sentry-triage/hackbot_agents/sentry_triage/rules/README.md new file mode 100644 index 0000000000..e9be87592b --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/rules/README.md @@ -0,0 +1,16 @@ +# Triage rules + +Drop `.md` files in this directory. Each file is one ruleset (e.g. +`general.md`, `crash-triage.md`). + +The agent does **not** load everything automatically — it Globs this +directory and Reads only the rulesets it judges relevant to the bug at +hand. Name your files descriptively and start each one with a short +paragraph explaining when it applies (e.g. "These rules apply to bugs +with a `sec-*` keyword."). + +Rules are free-form prose. Be explicit about: + +- **When** the rule applies (which products/components/keywords/states) +- **What** field changes or comments the agent should make +- **What confidence threshold** is needed before acting diff --git a/agents/sentry-triage/hackbot_agents/sentry_triage/rules/unsupported-config.md b/agents/sentry-triage/hackbot_agents/sentry_triage/rules/unsupported-config.md new file mode 100644 index 0000000000..1e995c0794 --- /dev/null +++ b/agents/sentry-triage/hackbot_agents/sentry_triage/rules/unsupported-config.md @@ -0,0 +1,48 @@ +# Unsupported Configurations + +Bugs often specify prefs that need to be enabled or disabled in order for the bug to reproduce. + +When a bug specifies prefs that are not the default on any supported platform or channel, it +becomes less important and the bug should be marked with the `unsupported-config` keyword. + +## Finding default state prefs + +Usually, the default values of prefs are found in `modules/libpref/init/all.js`. + +However, there are cases where a pref isn't in there, then you might have to search the +source code for how it exactly behaves. + +## Nightly-only features + +If a pref is enabled only on Nightly (i.e. guarded by some kind of ifdef that prevents it +from becoming active in release), that still counts as supported but it can be mentioned +in a comment that it is Nightly-only. + +## Pref only required for debugging/stability + +In some cases, bug reports specify prefs because it makes something **easier to reproduce**, +but the pref is not actually required to trigger the bug itself. For example, some bugs +specify that it needs `FuzzingFunctions` to trigger a GC reliably in a particular area, +but GC can also be triggered by other means, it just makes the testcase more reliable. + +Such cases must **not** be marked as `unsupported-config` because they could still apply without the pref set. It is important to try and disambiguate these two. + +However, if the crashing code itself is guarded by the pref and there is no clear other +path for the crash, then this is likely testing-only and therefore unsupported. + +## Channels / Versions + +Only label something as `unsupported-config` if that assessment would be true for all +currently supported channels. + +Currently supported versions are: ESR115, ESR140, 149, 150 and 151. + +If a pref was changed in any of these versions, you should outline that only certain +versions might be affected. + +## Commenting + +When adding the `unsupported-config` keyword, comment in at most 1-2 sentences +why you are adding the keyword. If the configuration is instead supported but there +are prefs mentioned in the bug, also comment in at most 1-2 sentences why these +are supported. diff --git a/agents/sentry-triage/pyproject.toml b/agents/sentry-triage/pyproject.toml new file mode 100644 index 0000000000..3bac7edbdc --- /dev/null +++ b/agents/sentry-triage/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "hackbot-agent-sentry-triage" +version = "0.1.0" +description = "Cloud Run Job image that runs the sentry-triage agent for hackbot-api" +requires-python = ">=3.12" +dependencies = [ + "hackbot-runtime[claude-sdk,phabricator]", + "agent-tools[sentry]", + "claude-agent-sdk>=0.1.30", + "mcp>=1.0.0", + "starlette>=0.36.0", + "uvicorn>=0.27.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0"] + +[tool.uv.sources] +hackbot-runtime = { workspace = true } +agent-tools = { workspace = true } +phabricator-client = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["hackbot_agents"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/agents/sentry-triage/tests/test_broker.py b/agents/sentry-triage/tests/test_broker.py new file mode 100644 index 0000000000..51fa0bb999 --- /dev/null +++ b/agents/sentry-triage/tests/test_broker.py @@ -0,0 +1,70 @@ +"""Tests for the broker's Phabricator patch route.""" + +from unittest.mock import AsyncMock + +from hackbot_agents.bug_fix import broker +from phabricator_client import PhabricatorDiff, PhabricatorSettings +from starlette.applications import Starlette +from starlette.testclient import TestClient + +VALID_TOKEN = "api-" + "a" * 28 + + +def _client(monkeypatch, fake) -> TestClient: + monkeypatch.setattr(broker, "PhabricatorClient", lambda settings: fake) + route = broker._phabricator_route(PhabricatorSettings(api_key=VALID_TOKEN)) + return TestClient(Starlette(routes=[route])) + + +def test_patch_route_returns_base_and_diff(monkeypatch): + fake = AsyncMock() + fake.query_latest_diff = AsyncMock( + return_value=PhabricatorDiff(id=9, base_commit="base9") + ) + fake.get_raw_diff = AsyncMock(return_value="diff --git a/f b/f\n") + # The abbreviated base is expanded to a full, fetchable hash. + fake.resolve_commit = AsyncMock(return_value="base9full") + + resp = _client(monkeypatch, fake).get("/phabricator/revision/42/patch") + + assert resp.status_code == 200 + assert resp.json() == { + "base_commit": "base9full", + "raw_diff": "diff --git a/f b/f\n", + } + fake.get_raw_diff.assert_awaited_once_with(9) + fake.resolve_commit.assert_awaited_once_with("base9") + + +def test_patch_route_falls_back_to_raw_base_when_unresolved(monkeypatch): + fake = AsyncMock() + fake.query_latest_diff = AsyncMock( + return_value=PhabricatorDiff(id=9, base_commit="base9") + ) + fake.get_raw_diff = AsyncMock(return_value="diff --git a/f b/f\n") + fake.resolve_commit = AsyncMock(return_value=None) + + resp = _client(monkeypatch, fake).get("/phabricator/revision/42/patch") + + assert resp.status_code == 200 + assert resp.json()["base_commit"] == "base9" + + +def test_patch_route_404_when_no_diff(monkeypatch): + fake = AsyncMock() + fake.query_latest_diff = AsyncMock(return_value=None) + + resp = _client(monkeypatch, fake).get("/phabricator/revision/42/patch") + + assert resp.status_code == 404 + + +def test_patch_route_404_when_no_base_commit(monkeypatch): + fake = AsyncMock() + fake.query_latest_diff = AsyncMock( + return_value=PhabricatorDiff(id=9, base_commit=None) + ) + + resp = _client(monkeypatch, fake).get("/phabricator/revision/42/patch") + + assert resp.status_code == 404 diff --git a/agents/sentry-triage/tests/test_inputs.py b/agents/sentry-triage/tests/test_inputs.py new file mode 100644 index 0000000000..ae31ffb7d1 --- /dev/null +++ b/agents/sentry-triage/tests/test_inputs.py @@ -0,0 +1,27 @@ +"""Tests for AgentInputs validation.""" + +import pytest +from hackbot_agents.bug_fix.__main__ import AgentInputs +from pydantic import ValidationError + + +def test_revision_requires_broker_url(monkeypatch): + monkeypatch.delenv("PHABRICATOR_BROKER_URL", raising=False) + with pytest.raises(ValidationError, match="phabricator_broker_url"): + AgentInputs(bug_id=1, bugzilla_mcp_url="http://x", revision_id=42) + + +def test_revision_with_broker_url_ok(): + inputs = AgentInputs( + bug_id=1, + bugzilla_mcp_url="http://x", + revision_id=42, + phabricator_broker_url="http://broker", + ) + assert inputs.phabricator_broker_url == "http://broker" + + +def test_no_revision_ok_without_broker_url(monkeypatch): + monkeypatch.delenv("PHABRICATOR_BROKER_URL", raising=False) + inputs = AgentInputs(bug_id=1, bugzilla_mcp_url="http://x") + assert inputs.revision_id is None diff --git a/libs/agent-tools/agent_tools/sentry.py b/libs/agent-tools/agent_tools/sentry.py new file mode 100644 index 0000000000..794f2c82a3 --- /dev/null +++ b/libs/agent-tools/agent_tools/sentry.py @@ -0,0 +1,44 @@ +import httpx + +from agent_tools.registry import ToolError, tool, tools_in +from dataclasses import dataclass +from pydantic import Field +from typing import Annotated, Any + + +@dataclass +class SentryContext: + api_token: str + api_url_base: str + client: httpx.AsyncClient + org_name: str + + +def _sentry_error(e: Exception, what: str, http_status_error: str) -> ToolError: + """Render a sentry failure as a structured, machine-parseable error.""" + return ToolError( + f"{what}: {e}", + payload={"error": http_status_error, "what": what, "message": str(e)}, + ) + + +@tool +async def get_issue_event( + ctx: SentryContext, + issue_id: Annotated[ + str, Field(description="The Sentry issue ID.") + ],event_id: str): + """Retrieve an issue event from Sentry.""" + try: + headers = { "Authorization": f"Bearer {ctx.api_token}" } + resp = await ctx.client.get(f"{ctx.api_url_base}/organizations/{ctx.org_name}/issues/{issue_id}/events/{event_id}/", headers=headers) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as e: + # get the http error, falling back to a generic "sentry_error" + http_status_error = {401: "auth_failed", 403: "access_denied", 404: "not_found"}.get(e.response.status_code, "sentry_error") + + raise _sentry_error(e, "get_issue_event error", http_status_error) from e + + +TOOLS = tools_in(__name__) \ No newline at end of file diff --git a/libs/agent-tools/pyproject.toml b/libs/agent-tools/pyproject.toml index 9c49fceb41..25b7ab0061 100644 --- a/libs/agent-tools/pyproject.toml +++ b/libs/agent-tools/pyproject.toml @@ -18,6 +18,7 @@ firefox = ["grizzly-framework", "prefpicker"] claude-sdk = ["claude-agent-sdk>=0.2.30"] searchfox = ["searchfox>=0.20.3"] vcs = ["httpx"] +sentry = ["httpx"] [build-system] requires = ["hatchling"] diff --git a/services/hackbot-api/app/agents.py b/services/hackbot-api/app/agents.py index 0876868949..8cc78743af 100644 --- a/services/hackbot-api/app/agents.py +++ b/services/hackbot-api/app/agents.py @@ -10,6 +10,7 @@ BugFixInputs, BuildRepairInputs, FrontendTriageInputs, + SentryTriageInputs, TestPlanGeneratorInputs, TestRepairInputs, ) @@ -126,4 +127,10 @@ def model_to_env(inputs: BaseModel) -> dict[str, str]: job_name="hackbot-agent-test-plan-generator", input_schema=TestPlanGeneratorInputs, ), + "sentry-triage": AgentSpec( + name="sentry-triage", + description="Investigate a Sentry alert and summarize the contributing cause(s)", + job_name="hackbot-agent-sentry-triage", + input_schema=SentryTriageInputs, + ), } diff --git a/services/hackbot-api/app/schemas.py b/services/hackbot-api/app/schemas.py index 50ed80ac04..a76b5fbd1f 100644 --- a/services/hackbot-api/app/schemas.py +++ b/services/hackbot-api/app/schemas.py @@ -150,3 +150,10 @@ class TestPlanGeneratorInputs(BaseModel): model: str | None = None max_turns: int | None = None effort: str | None = None + + +class SentryTriageInputs(BaseModel): + sentry_alert_url: str + model: str | None = None + max_turns: int | None = None + effort: str | None = None diff --git a/uv.lock b/uv.lock index a4e0436590..3ffa9ea9d3 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ members = [ "hackbot-agent-bug-fix", "hackbot-agent-build-repair", "hackbot-agent-frontend-triage", + "hackbot-agent-sentry-triage", "hackbot-agent-test-plan-generator", "hackbot-agent-test-repair", "hackbot-api", @@ -82,6 +83,9 @@ firefox = [ searchfox = [ { name = "searchfox" }, ] +sentry = [ + { name = "httpx" }, +] vcs = [ { name = "httpx" }, ] @@ -91,13 +95,14 @@ requires-dist = [ { name = "bugsy", marker = "extra == 'bugzilla'" }, { name = "claude-agent-sdk", marker = "extra == 'claude-sdk'", specifier = ">=0.2.30" }, { name = "grizzly-framework", marker = "extra == 'firefox'" }, + { name = "httpx", marker = "extra == 'sentry'" }, { name = "httpx", marker = "extra == 'vcs'" }, { name = "prefpicker", marker = "extra == 'firefox'" }, { name = "pydantic", specifier = ">=2.6.0" }, { name = "searchfox", marker = "extra == 'searchfox'", specifier = ">=0.20.3" }, { name = "six", marker = "extra == 'bugzilla'" }, ] -provides-extras = ["bugzilla", "firefox", "claude-sdk", "searchfox", "vcs"] +provides-extras = ["bugzilla", "firefox", "claude-sdk", "searchfox", "vcs", "sentry"] [[package]] name = "aiofile" @@ -2653,6 +2658,38 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "hackbot-agent-sentry-triage" +version = "0.1.0" +source = { editable = "agents/sentry-triage" } +dependencies = [ + { name = "agent-tools", extra = ["sentry"] }, + { name = "claude-agent-sdk" }, + { name = "hackbot-runtime", extra = ["claude-sdk", "phabricator"] }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-tools", extras = ["sentry"], editable = "libs/agent-tools" }, + { name = "claude-agent-sdk", specifier = ">=0.1.30" }, + { name = "hackbot-runtime", extras = ["claude-sdk", "phabricator"], editable = "libs/hackbot-runtime" }, + { name = "mcp", specifier = ">=1.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "starlette", specifier = ">=0.36.0" }, + { name = "uvicorn", specifier = ">=0.27.0" }, +] +provides-extras = ["dev"] + [[package]] name = "hackbot-agent-test-plan-generator" version = "0.1.0"