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
1 change: 1 addition & 0 deletions agents/bug-fix/agent_runner/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async def main(ctx: Context) -> AgentResult:
effort=inputs.effort,
log=log_path,
verbose=True,
actions_recorder=ctx.actions,
)

if log_path.exists() and ctx.uploader is not None:
Expand Down
6 changes: 2 additions & 4 deletions agents/bug-fix/broker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
class BrokerInputs(BaseSettings):
bugzilla_api_url: str
bugzilla_api_key: str
dry_run: bool = False
host: str = "0.0.0.0"
port: int = 8765

Expand All @@ -36,7 +35,7 @@ def build_app(inputs: BrokerInputs) -> Starlette:
client = bugsy.Bugsy(
api_key=inputs.bugzilla_api_key, bugzilla_url=inputs.bugzilla_api_url
)
ctx = BugzillaContext(client=client, dry_run=inputs.dry_run)
ctx = BugzillaContext(client=client)
sdk_config = build_bugzilla_server(ctx)
mcp_server = sdk_config["instance"]

Expand All @@ -46,10 +45,9 @@ def build_app(inputs: BrokerInputs) -> Starlette:
async def lifespan(app):
async with manager.run():
log.info(
"bugzilla broker ready on %s:%d (dry_run=%s)",
"bugzilla broker ready on %s:%d (read-only)",
inputs.host,
inputs.port,
inputs.dry_run,
)
yield

Expand Down
1 change: 0 additions & 1 deletion agents/bug-fix/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ services:
environment:
BUGZILLA_API_URL: ${BUGZILLA_API_URL}
BUGZILLA_API_KEY: ${BUGZILLA_API_KEY}
DRY_RUN: ${BROKER_DRY_RUN:-true}
expose:
- "8765"

Expand Down
319 changes: 319 additions & 0 deletions bugbug/tools/bug_fix/action_tools_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
"""In-process MCP server exposing the bug-fix agent's recording tools.

Translates MCP tool calls into ``ActionsRecorder.record(...)`` calls. The
recorded actions land in ``summary.json`` instead of hitting Bugzilla — a
downstream apply step replays them. The recorder itself lives in
``hackbot_runtime`` and has no MCP coupling, so the same recording API
can be wrapped for LangChain or any other agentic framework.
"""

from __future__ import annotations

import json
import mimetypes
import os
from dataclasses import dataclass
from pathlib import Path

from claude_agent_sdk import create_sdk_mcp_server, tool
from hackbot_runtime import ActionsRecorder


@dataclass
class ActionToolsContext:
"""Holds the shared recorder for all action-recording tools in this server."""

recorder: ActionsRecorder


def _text(content: str) -> dict:
return {"content": [{"type": "text", "text": content}]}


def _jtext(obj) -> dict:
return _text(json.dumps(obj, indent=2, default=str))


def build_server(ctx: ActionToolsContext):
"""Create the in-process MCP server bound to ``ctx``.

Tool schemas mirror the previous bugzilla write tools so the system
prompt and rules keep working — only the server prefix changes
(`mcp__bug_actions__*`) and the side effect is "record", not "apply".
"""

@tool(
"update_bug",
"Record an intended change to a Bugzilla bug. Accepts any field "
"the Bugzilla REST PUT endpoint accepts: status, resolution, "
"priority, severity, whiteboard, assigned_to, component, product, "
"version, and the keywords / cc / see_also / depends_on / blocks "
"set-operations ({'add': [...], 'remove': [...]}). Recorded into "
"the run summary for human review — does not modify Bugzilla.",
{
"type": "object",
"properties": {
"bug_id": {"type": "integer"},
"changes": {
"type": "object",
"description": (
"Field → new value. For list fields (keywords, cc, "
"blocks, depends_on, see_also) you may pass "
'{"add": [...], "remove": [...], "set": [...]}.'
),
"additionalProperties": True,
},
"reasoning": {
"type": "string",
"description": (
"Why you are recording this change. Stored alongside "
"the action so the human reviewer can audit."
),
},
},
"required": ["bug_id", "changes", "reasoning"],
},
)
async def update_bug(args):
bug_id = args["bug_id"]
changes = args["changes"]
reasoning = args["reasoning"]
ctx.recorder.record(
"bugzilla.update_bug",
{"bug_id": bug_id, "changes": changes},
reasoning=reasoning,
)
return _jtext(
{
"recorded": True,
"type": "bugzilla.update_bug",
"index": len(ctx.recorder.actions) - 1,
}
)

@tool(
"add_comment",
"Record an intended comment on a bug. Use is_private=true for "
"security-sensitive notes. Recorded into the run summary for "
"human review — does not post to Bugzilla.",
{
"type": "object",
"properties": {
"bug_id": {"type": "integer"},
"text": {"type": "string"},
"is_private": {
"type": "boolean",
"description": "Mark the comment private (security group only).",
},
"reasoning": {
"type": "string",
"description": "Why you are recording this comment (for audit log).",
},
},
"required": ["bug_id", "text", "reasoning"],
},
)
async def add_comment(args):
bug_id = args["bug_id"]
text = args["text"]
is_private = bool(args.get("is_private", False))
reasoning = args["reasoning"]

footer = (
"*This is an automated analysis result. If this result is incorrect "
"please add a needinfo and feel free to correct the error.* "
)
text = text.rstrip() + "\n\n" + footer

ctx.recorder.record(
"bugzilla.add_comment",
{"bug_id": bug_id, "text": text, "is_private": is_private},
reasoning=reasoning,
)
return _jtext(
{
"recorded": True,
"type": "bugzilla.add_comment",
"index": len(ctx.recorder.actions) - 1,
}
)

@tool(
"add_attachment",
"Record an intended file attachment on a bug. Pass a local "
"filesystem path — the runtime uploads a copy of the file as an "
"artifact so the apply step can fetch it. For patches, set "
"is_patch=true and omit content_type. Recorded into the run "
"summary for human review — does not upload to Bugzilla.",
{
"type": "object",
"properties": {
"bug_id": {"type": "integer"},
"file_path": {
"type": "string",
"description": "Local path to the file to attach.",
},
"summary": {
"type": "string",
"description": "Short description of the attachment. "
"Defaults to the filename.",
},
"content_type": {
"type": "string",
"description": "MIME type. Guessed from extension if "
"omitted. Ignored when is_patch=true.",
},
"is_patch": {
"type": "boolean",
"description": "Mark as a patch (Bugzilla forces "
"text/plain and enables diff view).",
},
"comment": {
"type": "string",
"description": "Optional comment to record alongside "
"the attachment.",
},
"reasoning": {
"type": "string",
"description": "Why you are attaching this (for audit log).",
},
},
"required": ["bug_id", "file_path", "reasoning"],
},
)
async def add_attachment(args):
bug_id = args["bug_id"]
file_path = args["file_path"]
reasoning = args["reasoning"]
is_patch = bool(args.get("is_patch", False))

if not os.path.isfile(file_path):
return {
"content": [
{
"type": "text",
"text": json.dumps(
{"error": "file_not_found", "path": file_path}
),
}
],
"is_error": True,
}

file_name = os.path.basename(file_path)
summary = args.get("summary") or file_name
size = os.path.getsize(file_path)

if is_patch:
content_type = "text/plain"
else:
content_type = args.get("content_type") or (
mimetypes.guess_type(file_path)[0] or "application/octet-stream"
)

params = {
"bug_id": bug_id,
"file_name": file_name,
"summary": summary,
"content_type": content_type,
"is_patch": is_patch,
"size_bytes": size,
}
comment = args.get("comment")
if comment:
footer = (
"*This is the analysis tool's suggested fix. Feel welcome "
"to adopt it as a starting point and evolve it as needed to "
"meet our coding standards.*"
)
params["comment"] = comment.rstrip() + "\n\n" + footer

ctx.recorder.record(
"bugzilla.add_attachment",
params,
reasoning=reasoning,
attachments={"file": Path(file_path)},
)
return _jtext(
{
"recorded": True,
"type": "bugzilla.add_attachment",
"index": len(ctx.recorder.actions) - 1,
}
)

@tool(
"create_bug",
"Record an intended new-bug filing. The description becomes "
"comment 0 and is rendered as Markdown. Pass any additional "
"Bugzilla POST /bug fields (severity, priority, keywords, "
"whiteboard, blocks, depends_on, cc, groups, op_sys, platform, "
"assigned_to, see_also, ...) via 'extra'. Recorded into the "
"run summary for human review — does not file in Bugzilla.",
{
"type": "object",
"properties": {
"product": {"type": "string"},
"component": {"type": "string"},
"summary": {"type": "string"},
"version": {"type": "string"},
"description": {
"type": "string",
"description": "Comment 0. Markdown is enabled.",
},
"extra": {
"type": "object",
"description": (
"Optional additional fields accepted by Bugzilla's "
"POST /bug endpoint. Merged into the recorded body."
),
"additionalProperties": True,
},
"reasoning": {
"type": "string",
"description": "Why you are filing this bug (for audit log).",
},
},
"required": [
"product",
"component",
"summary",
"version",
"description",
"reasoning",
],
},
)
async def create_bug(args):
reasoning = args["reasoning"]
body = {
"product": args["product"],
"component": args["component"],
"summary": args["summary"],
"version": args["version"],
"description": args["description"],
"is_markdown": True,
}
extra = args.get("extra") or {}
# Explicit top-level args win over anything smuggled in via extra.
for k, v in extra.items():
body.setdefault(k, v)

ctx.recorder.record(
"bugzilla.create_bug",
body,
reasoning=reasoning,
)
return _jtext(
{
"recorded": True,
"type": "bugzilla.create_bug",
"index": len(ctx.recorder.actions) - 1,
}
)

return create_sdk_mcp_server(
name="bug_actions",
version="0.1.0",
tools=[update_bug, add_comment, add_attachment, create_bug],
)
Loading