Skip to content
Draft
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
51 changes: 51 additions & 0 deletions agents/sentry-triage/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
36 changes: 36 additions & 0 deletions agents/sentry-triage/compose.yml
Original file line number Diff line number Diff line change
@@ -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/<run_id>, 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:
1 change: 1 addition & 0 deletions agents/sentry-triage/hackbot.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Empty file.
36 changes: 36 additions & 0 deletions agents/sentry-triage/hackbot_agents/sentry_triage/__main__.py
Original file line number Diff line number Diff line change
@@ -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)
169 changes: 169 additions & 0 deletions agents/sentry-triage/hackbot_agents/sentry_triage/agent.py
Original file line number Diff line number Diff line change
@@ -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&notification_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,
)
82 changes: 82 additions & 0 deletions agents/sentry-triage/hackbot_agents/sentry_triage/broker.py
Original file line number Diff line number Diff line change
@@ -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:<port>`. 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()
9 changes: 9 additions & 0 deletions agents/sentry-triage/hackbot_agents/sentry_triage/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Sentry MCP tool names as exposed to the agent (mcp__<server>__<tool>).
SENTRY_READ_TOOLS = [
"mcp__sentry__get_issue_event",
]

# Action types that the agent may record during triage/fix runs.
TRIAGE_ACTIONS = [
"slack.post_findings",
]
Loading