Skip to content
Merged
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
94 changes: 68 additions & 26 deletions src/reverse_api/auto_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ def __init__(
**kwargs,
):
"""Initialize auto engineer with expected HAR path (created by MCP)."""
# `headless` is auto-engineer specific (controls the MCP-spawned browser),
# not BaseEngineer concept; pop before super() to avoid an unknown kwarg.
headless = kwargs.pop("headless", False)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -50,6 +53,7 @@ def __init__(
)
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless

def _build_auto_prompts(self) -> tuple[str, str]:
"""Build (system_prompt, user_message) for auto mode.
Expand Down Expand Up @@ -112,22 +116,35 @@ async def _handle_tool_permission(self, tool_name: str, input_data: dict[str, An
return PermissionResultAllow(updated_input=input_data)

def _get_mcp_config(self) -> tuple[str, dict]:
"""Return (server_name, mcp_config) based on agent_provider."""
"""Return (server_name, mcp_config) based on agent_provider.

Auto-connect requires a real headed Chrome instance with a remote
debugging server, so it is dropped in headless mode and the MCP
spawns its own headless Chromium instead.
"""
if self.agent_provider == "chrome-mcp":
args = ["chrome-devtools-mcp@latest", "--no-usage-statistics"]
if self.headless:
args.append("--headless")
else:
args.append("--autoConnect")
return "chrome-devtools", {
"type": "stdio",
"command": "npx",
"args": ["chrome-devtools-mcp@latest", "--autoConnect", "--no-usage-statistics"],
"args": args,
}
playwright_args = [
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
]
if self.headless:
playwright_args.append("--headless")
return "playwright", {
"type": "stdio",
"command": "npx",
"args": [
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
],
"args": playwright_args,
}

async def analyze_and_generate(self) -> dict[str, Any] | None:
Expand Down Expand Up @@ -210,6 +227,7 @@ class OpenCodeAutoEngineer(OpenCodeEngineer):

def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agent_provider: str = "auto", **kwargs):
"""Initialize auto engineer with expected HAR path (created by MCP)."""
headless = kwargs.pop("headless", False)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -223,36 +241,50 @@ def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agen
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.mcp_name = None
self.headless = headless

def _get_active_prompts(self) -> tuple[str, str]:
return ClaudeAutoEngineer._build_auto_prompts(self)

def _get_opencode_mcp_config(self) -> dict:
"""Return OpenCode MCP registration payload based on agent_provider."""
"""Return OpenCode MCP registration payload based on agent_provider.

Auto-connect requires a headed Chrome with a remote debugging server,
so it is dropped in headless mode in favor of an MCP-spawned headless
Chromium.
"""
if self.agent_provider == "chrome-mcp":
self.mcp_name = f"chrome-devtools-{self._session_id}"
cmd = ["npx", "-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
if self.headless:
cmd.append("--headless")
else:
cmd.append("--autoConnect")
return {
"name": self.mcp_name,
"config": {
"type": "local",
"command": ["npx", "-y", "chrome-devtools-mcp@latest", "--autoConnect", "--no-usage-statistics"],
"command": cmd,
"enabled": True,
"timeout": 30000,
},
}
self.mcp_name = f"playwright-{self._session_id}"
cmd = [
"npx",
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
]
if self.headless:
cmd.append("--headless")
return {
"name": self.mcp_name,
"config": {
"type": "local",
"command": [
"npx",
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
],
"command": cmd,
"enabled": True,
"timeout": 30000,
},
Expand Down Expand Up @@ -450,6 +482,7 @@ def __init__(
):
from .copilot_engineer import CopilotEngineer

headless = kwargs.pop("headless", False)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -463,6 +496,7 @@ def __init__(
)
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless

def start_sync(self) -> None:
self._engineer.start_sync()
Expand Down Expand Up @@ -526,25 +560,33 @@ def on_event(event: Any) -> None:

if self.agent_provider == "chrome-mcp":
mcp_server_name = "chrome-devtools"
chrome_args = ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
if self.headless:
chrome_args.append("--headless")
else:
chrome_args.append("--autoConnect")
mcp_config = {
"type": "local",
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--autoConnect", "--no-usage-statistics"],
"args": chrome_args,
"tools": ["*"],
"timeout": 30000,
}
else:
mcp_server_name = "playwright"
pw_args = [
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
]
if self.headless:
pw_args.append("--headless")
mcp_config = {
"type": "local",
"command": "npx",
"args": [
"-y",
"rae-playwright-mcp@latest",
"run-mcp-server",
"--run-id",
self.mcp_run_id,
],
"args": pw_args,
"tools": ["*"],
"timeout": 30000,
}
Expand Down
16 changes: 14 additions & 2 deletions src/reverse_api/base_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
is_fresh: bool = False,
output_language: str = "python",
output_mode: str = "client",
interactive: bool = True,
):
self.run_id = run_id
self.har_path = har_path
Expand All @@ -67,6 +68,10 @@ def __init__(
self.sync_watcher: FileSyncWatcher | None = None
self.local_scripts_dir: Path | None = None
self._stderr_error_shown = False
# When False, _prompt_follow_up() returns None immediately so the
# conversation loop in subclasses ends after the first generation.
# Set this from --json / --no-interactive entry points.
self.interactive = interactive

def _handle_cli_stderr(self, line: str) -> None:
"""Filter CLI subprocess stderr. Shows full output in DEBUG mode, otherwise shows a single clean error."""
Expand Down Expand Up @@ -279,9 +284,16 @@ async def _ask_user_interactive(self, questions: list[dict[str, Any]]) -> dict[s
async def _prompt_follow_up(self) -> str | None:
"""Prompt user for a follow-up message. Returns None to finish.

Uses plain input() via executor instead of questionary to avoid
terminal state issues after the SDK subprocess exits.
In non-interactive mode (e.g. --json / --no-interactive) returns None
immediately so the conversation loop terminates after the first
generation. Otherwise uses plain input() via executor instead of
questionary to avoid terminal state issues after the SDK subprocess
exits.
"""
if not self.interactive:
# Still flush sync so any partial output reaches disk before we exit.
self.flush_sync()
return None
# Ensure all files are synced locally before waiting for user input
self.flush_sync()
self.ui.console.print()
Expand Down
Loading