diff --git a/agents/bug-fix/agent_runner/__main__.py b/agents/bug-fix/agent_runner/__main__.py index ec7f3d4d1b..d82c3584b0 100644 --- a/agents/bug-fix/agent_runner/__main__.py +++ b/agents/bug-fix/agent_runner/__main__.py @@ -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: diff --git a/agents/bug-fix/broker/__main__.py b/agents/bug-fix/broker/__main__.py index 9824936126..89b328b96b 100644 --- a/agents/bug-fix/broker/__main__.py +++ b/agents/bug-fix/broker/__main__.py @@ -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 @@ -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"] @@ -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 diff --git a/agents/bug-fix/compose.yml b/agents/bug-fix/compose.yml index bcc1d6ed84..d6cbdfbfaa 100644 --- a/agents/bug-fix/compose.yml +++ b/agents/bug-fix/compose.yml @@ -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" diff --git a/bugbug/tools/bug_fix/action_tools_mcp.py b/bugbug/tools/bug_fix/action_tools_mcp.py new file mode 100644 index 0000000000..1515c6f674 --- /dev/null +++ b/bugbug/tools/bug_fix/action_tools_mcp.py @@ -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], + ) diff --git a/bugbug/tools/bug_fix/agent.py b/bugbug/tools/bug_fix/agent.py index 9e2aecd15f..b3c3536224 100644 --- a/bugbug/tools/bug_fix/agent.py +++ b/bugbug/tools/bug_fix/agent.py @@ -27,11 +27,16 @@ ToolUseBlock, UserMessage, ) +from hackbot_runtime import ActionsRecorder from bugbug.tools.base import GenerativeModelTool +from bugbug.tools.bug_fix.action_tools_mcp import ActionToolsContext +from bugbug.tools.bug_fix.action_tools_mcp import ( + build_server as build_action_tools_server, +) from bugbug.tools.bug_fix.config import ( + BUG_ACTION_TOOLS, BUGZILLA_READ_TOOLS, - BUGZILLA_WRITE_TOOLS, FIREFOX_TOOLS, SOURCE_WRITE_TOOLS, ) @@ -224,6 +229,7 @@ async def run( effort: str | None = None, verbose: bool = False, log: Path | None = None, + actions_recorder: ActionsRecorder | None = None, ) -> BugFixResult: if rules_dir is None: rules_dir = HERE / "rules" @@ -239,12 +245,23 @@ async def run( fx_ctx = FirefoxContext.from_source_repo(source_repo) firefox_server = build_firefox_server(fx_ctx) + # --- Action-recording MCP server (in-process) --------------------- # + if actions_recorder is None: + actions_recorder = ActionsRecorder() + bug_actions_server = build_action_tools_server( + ActionToolsContext(recorder=actions_recorder) + ) + # --- Build agent options ------------------------------------------ # system_prompt = load_system_prompt(rules_dir, instructions) options = ClaudeAgentOptions( system_prompt=system_prompt, - mcp_servers={"bugzilla": bugzilla_mcp_server, "firefox": firefox_server}, + mcp_servers={ + "bugzilla": bugzilla_mcp_server, + "firefox": firefox_server, + "bug_actions": bug_actions_server, + }, agents={"investigator": make_investigator()}, cwd=str(source_repo.resolve()), add_dirs=[str(rules_dir.resolve())], @@ -257,7 +274,7 @@ async def run( "Task", *SOURCE_WRITE_TOOLS, *BUGZILLA_READ_TOOLS, - *BUGZILLA_WRITE_TOOLS, + *BUG_ACTION_TOOLS, *FIREFOX_TOOLS, ], model=model, diff --git a/bugbug/tools/bug_fix/bugzilla_mcp.py b/bugbug/tools/bug_fix/bugzilla_mcp.py index 51f86d92c1..558a7731da 100644 --- a/bugbug/tools/bug_fix/bugzilla_mcp.py +++ b/bugbug/tools/bug_fix/bugzilla_mcp.py @@ -1,18 +1,16 @@ """In-process MCP server wrapping bugsy for Bugzilla REST access. -Exposes read and write tools to a Claude agent. Write tools honour -dry-run and confirm modes. All tools gracefully handle proxy-level -restrictions (code 101: endpoint not exposed, code 102: access denied). +Exposes read-only tools to a Claude agent. Write actions are recorded +via the bug_actions MCP adapter (see ``action_tools_mcp.py``), so the +broker holds the Bugzilla API key but has no write capability at all. +All tools gracefully handle proxy-level restrictions (code 101: +endpoint not exposed, code 102: access denied). """ from __future__ import annotations -import asyncio import base64 import json -import mimetypes -import os -import sys from dataclasses import dataclass import bugsy @@ -25,16 +23,13 @@ @dataclass class BugzillaContext: - """Holds the live bugsy client and runtime flags. + """Holds the live bugsy client. The MCP tool functions close over a single instance of this class so - they can share auth and honour dry-run / confirm without re-parsing - CLI args. + they share auth and one TCP connection pool. """ client: bugsy.Bugsy - dry_run: bool = False - confirm: bool = False def _text(content: str) -> dict: @@ -75,24 +70,6 @@ def _handle_bugsy_error(e: bugsy.BugsyException) -> dict: } -async def _confirm_prompt(action: str, details: dict) -> bool: - """Interactively ask the user whether to proceed with a write. - - Runs ``input()`` in a thread so the event loop keeps turning — - the Agent SDK's subprocess reader stays alive while we wait on - the human. Prompt text goes to stderr so it doesn't tangle with - the streamed agent transcript on stdout. - """ - print(f"\n[CONFIRM] About to {action}:", file=sys.stderr) - print(json.dumps(details, indent=2, default=str), file=sys.stderr) - sys.stderr.flush() - try: - answer = await asyncio.to_thread(input, "Proceed? [y/N] ") - except EOFError: - answer = "" - return answer.strip().lower() in ("y", "yes") - - # --------------------------------------------------------------------------- # # Server factory # --------------------------------------------------------------------------- # @@ -102,8 +79,7 @@ def build_server(ctx: BugzillaContext): """Create and return the in-process MCP server bound to ``ctx``. All tool functions are closures over ``ctx`` so they share the same - bugsy session (one TCP connection pool, one auth header) and the same - dry-run / confirm state. + bugsy session (one TCP connection pool, one auth header). """ # ----- READ TOOLS -------------------------------------------------- # @@ -331,397 +307,6 @@ async def download_attachment(args): } ) - # ----- WRITE TOOLS ------------------------------------------------- # - - @tool( - "update_bug", - "Change fields on a 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': [...]}). Returns Bugzilla's change " - "report. Honours --dry-run and --confirm.", - { - "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 making this change. Logged alongside " - "dry-run / confirm output so the human can audit." - ), - }, - }, - "required": ["bug_id", "changes", "reasoning"], - }, - ) - async def update_bug(args): - bug_id = args["bug_id"] - changes = args["changes"] - reasoning = args["reasoning"] - - action_desc = { - "bug_id": bug_id, - "changes": changes, - "reasoning": reasoning, - } - - if ctx.dry_run: - print(f"\n[DRY-RUN] update_bug {bug_id}", file=sys.stderr) - print(json.dumps(action_desc, indent=2, default=str), file=sys.stderr) - return _jtext( - { - "dry_run": True, - "would_update": bug_id, - "changes": changes, - "note": "No request sent. Re-run without --dry-run to apply.", - } - ) - - if ctx.confirm and not await _confirm_prompt( - f"update bug {bug_id}", action_desc - ): - return _jtext( - { - "skipped": True, - "bug_id": bug_id, - "reason": "User declined at --confirm prompt.", - } - ) - - try: - result = ctx.client.request(f"bug/{bug_id}", method="PUT", json=changes) - except bugsy.BugsyException as e: - return _handle_bugsy_error(e) - return _jtext({"updated": bug_id, "result": result}) - - @tool( - "add_comment", - "Post a comment on a bug. Honours --dry-run and --confirm. " - "Use is_private=true for security-sensitive notes.", - { - "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 posting 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 - - action_desc = { - "bug_id": bug_id, - "is_private": is_private, - "reasoning": reasoning, - "text": text, - } - - if ctx.dry_run: - print(f"\n[DRY-RUN] add_comment on bug {bug_id}", file=sys.stderr) - print(json.dumps(action_desc, indent=2, default=str), file=sys.stderr) - return _jtext( - { - "dry_run": True, - "would_comment_on": bug_id, - "text_preview": text[:200], - "note": "No request sent. Re-run without --dry-run to apply.", - } - ) - - if ctx.confirm and not await _confirm_prompt( - f"comment on bug {bug_id}", action_desc - ): - return _jtext( - { - "skipped": True, - "bug_id": bug_id, - "reason": "User declined at --confirm prompt.", - } - ) - - body = {"comment": text, "is_markdown": True} - if is_private: - body["is_private"] = True - try: - result = ctx.client.request( - f"bug/{bug_id}/comment", method="POST", json=body - ) - except bugsy.BugsyException as e: - return _handle_bugsy_error(e) - return _jtext({"commented_on": bug_id, "comment_id": result.get("id")}) - - @tool( - "add_attachment", - "Upload a file as an attachment to a bug. Pass a local filesystem " - "path — the tool reads and base64-encodes the file itself (do NOT " - "inline file contents in the tool call). For patches, set " - "is_patch=true and omit content_type. Honours --dry-run and " - "--confirm.", - { - "type": "object", - "properties": { - "bug_id": {"type": "integer"}, - "file_path": { - "type": "string", - "description": "Local path to the file to upload.", - }, - "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 post alongside the " - "attachment. Posted as a separate " - "markdown-enabled comment after upload " - "(the attachment endpoint itself does " - "not support is_markdown).", - }, - "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" - ) - - action_desc = { - "bug_id": bug_id, - "file_path": file_path, - "file_name": file_name, - "size_bytes": size, - "content_type": content_type, - "is_patch": is_patch, - "summary": summary, - "reasoning": reasoning, - } - - if ctx.dry_run: - print(f"\n[DRY-RUN] add_attachment on bug {bug_id}", file=sys.stderr) - print(json.dumps(action_desc, indent=2, default=str), file=sys.stderr) - return _jtext( - { - "dry_run": True, - "would_attach_to": bug_id, - "file_name": file_name, - "size_bytes": size, - "note": "No request sent. Re-run without --dry-run to apply.", - } - ) - - if ctx.confirm and not await _confirm_prompt( - f"attach {file_name} ({size} bytes) to bug {bug_id}", action_desc - ): - return _jtext( - { - "skipped": True, - "bug_id": bug_id, - "reason": "User declined at --confirm prompt.", - } - ) - - # Read + encode here, server-side — keeps the agent's tool call tiny - # (just a path string) instead of forcing it to stream base64 tokens. - with open(file_path, "rb") as fp: - data = base64.b64encode(fp.read()).decode("ascii") - - body = { - "ids": [bug_id], - "data": data, - "file_name": file_name, - "summary": summary, - "content_type": content_type, - "is_patch": is_patch, - } - - try: - result = ctx.client.request( - f"bug/{bug_id}/attachment", method="POST", json=body - ) - except bugsy.BugsyException as e: - return _handle_bugsy_error(e) - - response = {"attached_to": bug_id, "result": result} - - # The attachment endpoint's inline comment field doesn't honour - # is_markdown — post the comment separately so markdown renders. - 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.*" - ) - comment = comment.rstrip() + "\n\n" + footer - try: - cres = ctx.client.request( - f"bug/{bug_id}/comment", - method="POST", - json={"comment": comment, "is_markdown": True}, - ) - response["comment_id"] = cres.get("id") - except bugsy.BugsyException as e: - # Attachment already succeeded; surface the comment failure - # without clobbering that fact. - response["comment_error"] = str(e) - - return _jtext(response) - - @tool( - "create_bug", - "File a new bug. Requires the proxy key to have allow_create=true. " - "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'. " - "Honours --dry-run and --confirm.", - { - "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 request 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) - - action_desc = {"reasoning": reasoning, "body": body} - - if ctx.dry_run: - print("\n[DRY-RUN] create_bug", file=sys.stderr) - print(json.dumps(action_desc, indent=2, default=str), file=sys.stderr) - return _jtext( - { - "dry_run": True, - "would_create": body["summary"], - "body": body, - "note": "No request sent. Re-run without --dry-run to apply.", - } - ) - - if ctx.confirm and not await _confirm_prompt("create bug", action_desc): - return _jtext( - { - "skipped": True, - "reason": "User declined at --confirm prompt.", - } - ) - - try: - result = ctx.client.request("bug", method="POST", json=body) - except bugsy.BugsyException as e: - return _handle_bugsy_error(e) - return _jtext({"created": result.get("id"), "result": result}) - return create_sdk_mcp_server( name="bugzilla", version="0.1.0", @@ -731,9 +316,5 @@ async def create_bug(args): get_bug_comments, get_bug_attachments, download_attachment, - update_bug, - add_comment, - add_attachment, - create_bug, ], ) diff --git a/bugbug/tools/bug_fix/config.py b/bugbug/tools/bug_fix/config.py index 4b9f974b1c..6cfd5f57f1 100644 --- a/bugbug/tools/bug_fix/config.py +++ b/bugbug/tools/bug_fix/config.py @@ -15,11 +15,14 @@ "mcp__bugzilla__get_bug_attachments", "mcp__bugzilla__download_attachment", ] -BUGZILLA_WRITE_TOOLS = [ - "mcp__bugzilla__update_bug", - "mcp__bugzilla__add_comment", - "mcp__bugzilla__add_attachment", - "mcp__bugzilla__create_bug", +# Recording adapter — these tools land in summary.json's `actions` array +# instead of mutating Bugzilla. Served by the in-process bug_actions MCP +# server (see action_tools_mcp.py). +BUG_ACTION_TOOLS = [ + "mcp__bug_actions__update_bug", + "mcp__bug_actions__add_comment", + "mcp__bug_actions__add_attachment", + "mcp__bug_actions__create_bug", ] # Firefox build/test tools. diff --git a/bugbug/tools/bug_fix/prompts/system.md b/bugbug/tools/bug_fix/prompts/system.md index a9a835dde5..e485751fb0 100644 --- a/bugbug/tools/bug_fix/prompts/system.md +++ b/bugbug/tools/bug_fix/prompts/system.md @@ -64,22 +64,24 @@ Use it when: When you spawn an investigator via the Task tool, write a complete, self-contained prompt: what to look at, what question to answer, what format to return. The investigator has no memory of previous spawns. -# Confidence and acting +# Recording actions -Before calling `update_bug` or `add_comment`, state in your response: +The `bug_actions` MCP tools (`update_bug`, `add_comment`, `add_attachment`, `create_bug`) do **not** mutate Bugzilla directly. They record an intended action into the run's `summary.json` for a human reviewer (or a downstream apply step) to enact. Treat each recorded action as a final, irrevocable proposal — once recorded it appears in the run output verbatim. -- **What** you are about to change and **why** (cite the specific rule) +Before calling any `bug_actions` tool, state in your response: + +- **What** action you are recording and **why** (cite the specific rule) - **Your confidence**: high / medium / low -Only call `update_bug` to change fields when confidence is **high** and a specific triage rule directs it. If confidence is medium or low, `add_comment` instead to ask for clarification or note your findings — do not silently skip. +Only record an `update_bug` action when confidence is **high** and a specific triage rule directs it. If confidence is medium or low, record an `add_comment` instead to ask for clarification or note your findings — do not silently skip. -Never set `status: RESOLVED` unless a rule explicitly covers that case and you have verified the resolution condition. +Never record `status: RESOLVED` unless a rule explicitly covers that case and you have verified the resolution condition. -The `reasoning` parameter on `update_bug` / `add_comment` is required and logged. Fill it properly. +The `reasoning` parameter on every `bug_actions` tool is required and stored alongside the recorded action. Fill it properly. -Always be **brief** and to the point. Do not post long-winded comments, developers have limited time to find the necessary information. +Always be **brief** and to the point. Do not record long-winded comments — developers have limited time to find the necessary information. -Do **not** post private comments, all developers on the bug need to see the comments. +Do **not** record private comments, all developers on the bug need to see the comments. Source-repo edits (Write/Edit) are allowed so you can prepare and inspect a candidate patch. diff --git a/bugbug/tools/duplicate_bugs/agent.py b/bugbug/tools/duplicate_bugs/agent.py index 3ce535754e..752c01ac4e 100644 --- a/bugbug/tools/duplicate_bugs/agent.py +++ b/bugbug/tools/duplicate_bugs/agent.py @@ -498,7 +498,7 @@ async def run( raise ValueError("meta_bug is required for local/bugs modes") bz = bugsy.Bugsy(api_key=api_key, bugzilla_url=base_url) - bz_ctx = BugzillaContext(client=bz, dry_run=True) + bz_ctx = BugzillaContext(client=bz) bugzilla_server = build_bugzilla_server(bz_ctx) if mode == "local": diff --git a/libs/hackbot-runtime/hackbot_runtime/__init__.py b/libs/hackbot-runtime/hackbot_runtime/__init__.py index 7f97491b3e..57e83a6d4b 100644 --- a/libs/hackbot-runtime/hackbot_runtime/__init__.py +++ b/libs/hackbot-runtime/hackbot_runtime/__init__.py @@ -1,9 +1,11 @@ +from hackbot_runtime.actions import ActionsRecorder from hackbot_runtime.context import Context from hackbot_runtime.result import AgentResult from hackbot_runtime.runtime import run, run_async from hackbot_runtime.uploader import SignedPolicyUploader __all__ = [ + "ActionsRecorder", "AgentResult", "Context", "SignedPolicyUploader", diff --git a/libs/hackbot-runtime/hackbot_runtime/actions.py b/libs/hackbot-runtime/hackbot_runtime/actions.py new file mode 100644 index 0000000000..a674d74733 --- /dev/null +++ b/libs/hackbot-runtime/hackbot_runtime/actions.py @@ -0,0 +1,57 @@ +from pathlib import Path + +from hackbot_runtime.uploader import SignedPolicyUploader + + +class ActionsRecorder: + """Collects structured actions an agent decided to take. + + The runtime serialises the collected list into the + ``actions`` array of ``summary.json``; a downstream apply step picks + them up from there. + + Framework-agnostic: knows nothing about MCP, LangChain, or any specific + action domain. Per-framework adapters wrap this and translate their + native tool calls into ``record(...)`` calls. + """ + + def __init__(self, uploader: SignedPolicyUploader | None = None) -> None: + self._actions: list[dict] = [] + self._uploader = uploader + + def record( + self, + type: str, + params: dict, + *, + reasoning: str | None = None, + attachments: dict[str, Path] | None = None, + ) -> dict: + """Record an intended action. + + ``type`` uses ``.`` (e.g. ``bugzilla.update_bug``, + ``phabricator.create_revision``). ``params`` is action-specific + data the apply step will need. ``attachments`` maps a logical name + to a local file path; each file is uploaded to + ``attachments//`` via the runtime uploader and + the recorded action references it by the uploaded key (the local + path is not persisted, since it disappears with the container). + """ + idx = len(self._actions) + action: dict = {"type": type, "params": params, "reasoning": reasoning} + + if attachments: + recorded_attachments: list[dict] = [] + for name, path in attachments.items(): + key = f"attachments/{idx}/{name}" + if self._uploader is not None: + self._uploader.upload_file(key, path) + recorded_attachments.append({"name": name, "uploaded_key": key}) + action["attachments"] = recorded_attachments + + self._actions.append(action) + return action + + @property + def actions(self) -> list[dict]: + return list(self._actions) diff --git a/libs/hackbot-runtime/hackbot_runtime/context.py b/libs/hackbot-runtime/hackbot_runtime/context.py index 0948a7b318..702ebbcf75 100644 --- a/libs/hackbot-runtime/hackbot_runtime/context.py +++ b/libs/hackbot-runtime/hackbot_runtime/context.py @@ -2,6 +2,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +from hackbot_runtime.actions import ActionsRecorder from hackbot_runtime.uploader import SignedPolicyUploader @@ -30,3 +31,7 @@ def uploader(self) -> SignedPolicyUploader | None: fields=self.results_policy_fields, prefix=self.results_prefix, ) + + @cached_property + def actions(self) -> ActionsRecorder: + return ActionsRecorder(self.uploader) diff --git a/libs/hackbot-runtime/hackbot_runtime/result.py b/libs/hackbot-runtime/hackbot_runtime/result.py index 3bf88041ec..0f525ecd19 100644 --- a/libs/hackbot-runtime/hackbot_runtime/result.py +++ b/libs/hackbot-runtime/hackbot_runtime/result.py @@ -8,10 +8,13 @@ class AgentResult: The runtime serialises this into the summary.json artifact the orchestrator reads. `status` drives the run's terminal state in hackbot-api; `findings` - is opaque to the platform and surfaced verbatim. + is opaque to the platform and surfaced verbatim. `actions` lets an agent + pass through its own action list; when empty, the runtime falls back to + the recorder on Context. """ status: Literal["ok", "error"] = "ok" error: str | None = None findings: dict[str, Any] = field(default_factory=dict) + actions: list[dict] = field(default_factory=list) exit_code: int = 0 diff --git a/libs/hackbot-runtime/hackbot_runtime/runtime.py b/libs/hackbot-runtime/hackbot_runtime/runtime.py index ee343c4ea0..160a7bae95 100644 --- a/libs/hackbot-runtime/hackbot_runtime/runtime.py +++ b/libs/hackbot-runtime/hackbot_runtime/runtime.py @@ -26,19 +26,25 @@ def _configure_logging() -> None: ) -def _summary_payload_from_result(result: AgentResult) -> dict: +def _summary_payload_from_result(result: AgentResult, ctx: Context) -> dict: + # Prefer actions the agent passed back explicitly; fall back to the + # recorder shared via Context so agents that just record never need + # to thread the list through their own result. + actions = result.actions or ctx.actions.actions return { "status": result.status, "error": result.error, "findings": result.findings, + "actions": actions, } -def _summary_payload_from_exception(exc: BaseException) -> dict: +def _summary_payload_from_exception(exc: BaseException, ctx: Context) -> dict: return { "status": "error", "error": f"{type(exc).__name__}: {exc}", "findings": {"traceback": traceback.format_exc()}, + "actions": ctx.actions.actions, } @@ -55,10 +61,10 @@ def _load_context() -> Context | None: def _finish(ctx: Context, result_or_exc: AgentResult | BaseException) -> int: if isinstance(result_or_exc, AgentResult): - payload = _summary_payload_from_result(result_or_exc) + payload = _summary_payload_from_result(result_or_exc, ctx) exit_code = result_or_exc.exit_code else: - payload = _summary_payload_from_exception(result_or_exc) + payload = _summary_payload_from_exception(result_or_exc, ctx) exit_code = 1 if ctx.uploader is None: diff --git a/scripts/run_bug_fix.py b/scripts/run_bug_fix.py index 5f4cbf3811..9eb9e2961e 100644 --- a/scripts/run_bug_fix.py +++ b/scripts/run_bug_fix.py @@ -35,7 +35,6 @@ async def main(): api_key=settings.bugzilla_api_key, bugzilla_url=settings.bugzilla_api_url, ), - dry_run=True, ) )