From c8f86522401e3af3bf8f840355fc3a84bd3569d4 Mon Sep 17 00:00:00 2001 From: Drake Date: Mon, 16 Mar 2026 12:34:14 -0400 Subject: [PATCH 1/3] Add native Chrome DevTools MCP integration --- README.md | 57 +- agent/__main__.py | 98 +++ agent/builder.py | 6 + agent/chrome_mcp.py | 572 +++++++++++++++++ agent/config.py | 67 +- agent/engine.py | 32 +- agent/settings.py | 76 +++ agent/tool_defs.py | 31 + agent/tools.py | 63 ++ agent/tui.py | 104 ++- .../crates/op-core/src/config.rs | 101 ++- .../crates/op-core/src/config_hydration.rs | 39 +- .../crates/op-core/src/engine/mod.rs | 38 +- .../crates/op-core/src/events.rs | 14 + .../crates/op-core/src/settings.rs | 89 ++- .../crates/op-core/src/tools/chrome_mcp.rs | 596 ++++++++++++++++++ .../crates/op-core/src/tools/defs.rs | 58 +- .../crates/op-core/src/tools/mod.rs | 48 +- .../crates/op-tauri/src/bridge.rs | 72 ++- .../crates/op-tauri/src/commands/agent.rs | 4 +- .../crates/op-tauri/src/commands/config.rs | 112 +++- .../crates/op-tauri/src/state.rs | 85 +++ openplanter-desktop/frontend/src/api/types.ts | 20 + .../frontend/src/commands/chrome.test.ts | 126 ++++ .../frontend/src/commands/chrome.ts | 138 ++++ .../src/commands/completionRegistry.test.ts | 26 + .../src/commands/completionRegistry.ts | 33 + .../frontend/src/commands/slash.test.ts | 26 + .../frontend/src/commands/slash.ts | 7 + .../frontend/src/components/App.ts | 1 + .../frontend/src/components/ChatPane.test.ts | 39 +- .../frontend/src/components/ChatPane.ts | 29 +- .../src/components/contentParser.test.ts | 14 +- .../frontend/src/components/contentParser.ts | 19 +- .../frontend/src/components/toolArgs.ts | 160 +++++ openplanter-desktop/frontend/src/main.ts | 9 + .../frontend/src/state/store.ts | 16 + tests/test_chrome_mcp.py | 207 ++++++ tests/test_engine.py | 48 ++ tests/test_settings.py | 38 +- tests/test_tool_defs.py | 39 ++ tests/test_tui_repl.py | 47 ++ 42 files changed, 3279 insertions(+), 125 deletions(-) create mode 100644 agent/chrome_mcp.py create mode 100644 openplanter-desktop/crates/op-core/src/tools/chrome_mcp.rs create mode 100644 openplanter-desktop/frontend/src/commands/chrome.test.ts create mode 100644 openplanter-desktop/frontend/src/commands/chrome.ts create mode 100644 openplanter-desktop/frontend/src/components/toolArgs.ts create mode 100644 tests/test_chrome_mcp.py diff --git a/README.md b/README.md index a70daf62..f3490be7 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ cargo tauri build Requires: Rust stable, Node.js 20+, and platform-specific Tauri dependencies ([see Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)). +If you want the desktop app to control a live Chrome session through Chrome DevTools MCP, keep a local Node/npm install available at runtime. OpenPlanter shells out to `npx -y chrome-devtools-mcp@latest`; it does not bundle the server or launch Chrome for you. + ## CLI Agent The Python CLI agent can be used independently of the desktop app. @@ -71,6 +73,8 @@ Or run a single task headlessly: openplanter-agent --task "Cross-reference vendor payments against lobbying disclosures and flag overlaps" --workspace ./data ``` +Chrome DevTools MCP support in the CLI/TUI also uses local `npx`, so install Node.js 20+ if you want to enable Chrome tools there. + ### Docker ```bash @@ -142,6 +146,53 @@ The agent has access to 20 tools, organized around its investigation workflow: In **recursive mode** (the default), the agent spawns sub-agents via `subtask` and `execute` to parallelize entity resolution, cross-dataset linking, and evidence-chain construction across large investigations. +When Chrome DevTools MCP is enabled, OpenPlanter discovers Chrome's published MCP tools at solve start and appends them natively to the built-in tool set for the main agent, recursive subtasks, and execute flows. + +## Chrome DevTools MCP + +OpenPlanter can attach to the official Chrome DevTools MCP server and reuse an active Chrome debugging session. The integration is native in both runtimes, but the server itself is still the upstream package started locally through `npx`. + +### Requirements + +- Node.js and npm available on your `PATH` +- Chrome 144 or newer +- Remote debugging enabled in Chrome at `chrome://inspect/#remote-debugging` + +### How OpenPlanter Connects + +- Auto-connect mode: OpenPlanter starts `chrome-devtools-mcp` with `--autoConnect` and reuses a running Chrome session after you approve Chrome's debugging prompt. +- Browser URL mode: OpenPlanter passes `--browserUrl ` to attach to an existing remote debugging endpoint. This takes precedence over auto-connect when configured. +- Channel selection: `stable` is the default channel; you can switch to `beta`, `dev`, or `canary` when needed. + +If Chrome MCP cannot start because Node/npm is missing, Chrome remote debugging is disabled, or Chrome is not available, OpenPlanter keeps running with its built-in tools and reports Chrome MCP as `unavailable`. + +### Desktop Usage + +Use the desktop slash command: + +```text +/chrome status +/chrome on +/chrome off +/chrome auto --save +/chrome url http://127.0.0.1:9222 --save +/chrome channel beta --save +``` + +The sidebar and `/status` output both show the current Chrome MCP runtime state. + +### CLI Usage + +Use per-run flags: + +```bash +openplanter-agent --chrome-mcp --chrome-auto-connect +openplanter-agent --chrome-mcp --chrome-browser-url http://127.0.0.1:9222 +openplanter-agent --chrome-mcp --chrome-channel beta +``` + +The TUI also supports `/chrome status|on|off|auto|url |channel [--save]`. + ## CLI Reference ``` @@ -181,6 +232,10 @@ OPENPLANTER_WORKSPACE=workspace | `--provider NAME` | `auto`, `openai`, `anthropic`, `openrouter`, `cerebras`, `ollama` | | `--model NAME` | Model name or `newest` to auto-select | | `--reasoning-effort LEVEL` | `low`, `medium`, `high`, or `none` | +| `--chrome-mcp` / `--no-chrome-mcp` | Enable or disable native Chrome DevTools MCP tools | +| `--chrome-auto-connect` / `--no-chrome-auto-connect` | Use Chrome MCP auto-connect or require an explicit browser URL | +| `--chrome-browser-url URL` | Attach Chrome MCP to an existing remote debugging browser URL | +| `--chrome-channel CHANNEL` | Chrome release channel for auto-connect: `stable`, `beta`, `dev`, `canary` | | `--list-models` | Fetch available models from the provider API | ### Execution @@ -204,7 +259,7 @@ OPENPLANTER_WORKSPACE=workspace ### Persistent Defaults -Use `--default-model`, `--default-reasoning-effort`, or per-provider variants like `--default-model-openai` to save workspace defaults to `.openplanter/settings.json`. View them with `--show-settings`. +Use `--default-model`, `--default-reasoning-effort`, Chrome MCP slash commands with `--save`, or per-provider variants like `--default-model-openai` to save workspace defaults to `.openplanter/settings.json`. View them with `--show-settings`. ## Configuration diff --git a/agent/__main__.py b/agent/__main__.py index d42ca40e..069a5fd8 100644 --- a/agent/__main__.py +++ b/agent/__main__.py @@ -85,6 +85,40 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--openrouter-api-key", help="OpenRouter API key override.") parser.add_argument("--cerebras-api-key", help="Cerebras API key override.") parser.add_argument("--exa-api-key", help="Exa API key override.") + parser.add_argument( + "--chrome-mcp", + dest="chrome_mcp_enabled", + action="store_true", + help="Enable native Chrome DevTools MCP tools for this run.", + ) + parser.add_argument( + "--no-chrome-mcp", + dest="chrome_mcp_enabled", + action="store_false", + help="Disable native Chrome DevTools MCP tools for this run.", + ) + parser.add_argument( + "--chrome-auto-connect", + dest="chrome_auto_connect", + action="store_true", + help="Ask the Chrome DevTools MCP server to auto-connect to a running Chrome instance.", + ) + parser.add_argument( + "--no-chrome-auto-connect", + dest="chrome_auto_connect", + action="store_false", + help="Disable Chrome MCP auto-connect and rely on --chrome-browser-url instead.", + ) + parser.add_argument( + "--chrome-browser-url", + help="Remote debugging browser URL for Chrome DevTools MCP (preferred over auto-connect).", + ) + parser.add_argument( + "--chrome-channel", + choices=["stable", "beta", "dev", "canary"], + help="Chrome channel to target when Chrome MCP auto-connect is used.", + ) + parser.set_defaults(chrome_mcp_enabled=None, chrome_auto_connect=None) parser.add_argument("--voyage-api-key", help="Voyage API key override.") parser.add_argument( "--configure-keys", @@ -329,6 +363,16 @@ def _apply_runtime_overrides(cfg: AgentConfig, args: argparse.Namespace, creds: cfg.model = args.model if args.reasoning_effort: cfg.reasoning_effort = None if args.reasoning_effort == "none" else args.reasoning_effort + if args.chrome_mcp_enabled is not None: + cfg.chrome_mcp_enabled = bool(args.chrome_mcp_enabled) + if args.chrome_auto_connect is not None: + cfg.chrome_mcp_auto_connect = bool(args.chrome_auto_connect) + if args.chrome_browser_url is not None: + cfg.chrome_mcp_browser_url = args.chrome_browser_url.strip() or None + if cfg.chrome_mcp_browser_url: + cfg.chrome_mcp_enabled = True + if args.chrome_channel: + cfg.chrome_mcp_channel = args.chrome_channel if args.recursive: cfg.recursive = True if args.acceptance_criteria: @@ -419,6 +463,40 @@ def _apply_persistent_settings( and settings.default_reasoning_effort ): cfg.reasoning_effort = settings.default_reasoning_effort + if ( + args.chrome_mcp_enabled is None + and os.getenv("OPENPLANTER_CHROME_MCP_ENABLED") is None + and settings.chrome_mcp_enabled is not None + ): + cfg.chrome_mcp_enabled = settings.chrome_mcp_enabled + if ( + args.chrome_auto_connect is None + and os.getenv("OPENPLANTER_CHROME_MCP_AUTO_CONNECT") is None + and settings.chrome_mcp_auto_connect is not None + ): + cfg.chrome_mcp_auto_connect = settings.chrome_mcp_auto_connect + if ( + args.chrome_browser_url is None + and os.getenv("OPENPLANTER_CHROME_MCP_BROWSER_URL") is None + and settings.chrome_mcp_browser_url + ): + cfg.chrome_mcp_browser_url = settings.chrome_mcp_browser_url + if ( + args.chrome_channel is None + and os.getenv("OPENPLANTER_CHROME_MCP_CHANNEL") is None + and settings.chrome_mcp_channel + ): + cfg.chrome_mcp_channel = settings.chrome_mcp_channel + if ( + os.getenv("OPENPLANTER_CHROME_MCP_CONNECT_TIMEOUT_SEC") is None + and settings.chrome_mcp_connect_timeout_sec is not None + ): + cfg.chrome_mcp_connect_timeout_sec = settings.chrome_mcp_connect_timeout_sec + if ( + os.getenv("OPENPLANTER_CHROME_MCP_RPC_TIMEOUT_SEC") is None + and settings.chrome_mcp_rpc_timeout_sec is not None + ): + cfg.chrome_mcp_rpc_timeout_sec = settings.chrome_mcp_rpc_timeout_sec return settings @@ -432,6 +510,24 @@ def _print_settings(settings: PersistentSettings) -> None: print(f" default_model_openrouter: {settings.default_model_openrouter or '(unset)'}") print(f" default_model_cerebras: {settings.default_model_cerebras or '(unset)'}") print(f" default_model_ollama: {settings.default_model_ollama or '(unset)'}") + print( + " chrome_mcp_enabled: " + f"{settings.chrome_mcp_enabled if settings.chrome_mcp_enabled is not None else '(unset)'}" + ) + print( + " chrome_mcp_auto_connect: " + f"{settings.chrome_mcp_auto_connect if settings.chrome_mcp_auto_connect is not None else '(unset)'}" + ) + print(f" chrome_mcp_browser_url: {settings.chrome_mcp_browser_url or '(unset)'}") + print(f" chrome_mcp_channel: {settings.chrome_mcp_channel or '(unset)'}") + print( + " chrome_mcp_connect_timeout_sec: " + f"{settings.chrome_mcp_connect_timeout_sec if settings.chrome_mcp_connect_timeout_sec is not None else '(unset)'}" + ) + print( + " chrome_mcp_rpc_timeout_sec: " + f"{settings.chrome_mcp_rpc_timeout_sec if settings.chrome_mcp_rpc_timeout_sec is not None else '(unset)'}" + ) def _has_non_interactive_command(args: argparse.Namespace) -> bool: @@ -566,6 +662,7 @@ def main() -> None: engine = build_engine(cfg) model_name = _get_model_display_name(engine) + chrome_status = engine.tools.chrome_mcp_status() try: runtime = SessionRuntime.bootstrap( @@ -585,6 +682,7 @@ def main() -> None: if cfg.reasoning_effort: startup_info["Reasoning"] = cfg.reasoning_effort startup_info["Mode"] = "recursive" if cfg.recursive else "flat" + startup_info["ChromeMCP"] = f"{chrome_status.status}: {chrome_status.detail}" startup_info["Workspace"] = str(cfg.workspace) startup_info["WorkspaceSource"] = workspace_resolution.source if workspace_resolution.guardrail_action != "none": diff --git a/agent/builder.py b/agent/builder.py index 6abb6887..26285516 100644 --- a/agent/builder.py +++ b/agent/builder.py @@ -173,6 +173,12 @@ def build_engine(cfg: AgentConfig) -> RLMEngine: mistral_transcription_chunk_overlap_seconds=cfg.mistral_transcription_chunk_overlap_seconds, mistral_transcription_max_chunks=cfg.mistral_transcription_max_chunks, mistral_transcription_request_timeout_sec=cfg.mistral_transcription_request_timeout_sec, + chrome_mcp_enabled=cfg.chrome_mcp_enabled, + chrome_mcp_auto_connect=cfg.chrome_mcp_auto_connect, + chrome_mcp_browser_url=cfg.chrome_mcp_browser_url, + chrome_mcp_channel=cfg.chrome_mcp_channel, + chrome_mcp_connect_timeout_sec=cfg.chrome_mcp_connect_timeout_sec, + chrome_mcp_rpc_timeout_sec=cfg.chrome_mcp_rpc_timeout_sec, max_observation_chars=cfg.max_observation_chars, ) diff --git a/agent/chrome_mcp.py b/agent/chrome_mcp.py new file mode 100644 index 00000000..50112c1b --- /dev/null +++ b/agent/chrome_mcp.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import atexit +import json +import os +import shlex +import shutil +import subprocess +import threading +import time +from dataclasses import dataclass +from typing import Any + +from .config import ( + CHROME_MCP_DEFAULT_CHANNEL, + normalize_chrome_mcp_browser_url, + normalize_chrome_mcp_channel, +) + + +class ChromeMcpError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ChromeMcpToolDef: + name: str + description: str + parameters: dict[str, Any] + + def as_tool_definition(self) -> dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + } + + +@dataclass(frozen=True) +class ChromeMcpImage: + base64_data: str + media_type: str + + +@dataclass(frozen=True) +class ChromeMcpCallResult: + content: str + is_error: bool = False + image: ChromeMcpImage | None = None + + +@dataclass(frozen=True) +class ChromeMcpStatus: + status: str + detail: str + tool_count: int = 0 + last_refresh_at: float | None = None + + +@dataclass +class _PendingRequest: + event: threading.Event + result: dict[str, Any] | None = None + error: Exception | None = None + + +def _env_text(name: str, default: str) -> str: + value = (os.getenv(name) or "").strip() + return value or default + + +def _format_protocol_error(error: object) -> str: + if isinstance(error, dict): + message = str(error.get("message") or "Unknown MCP error").strip() + code = error.get("code") + if code is None: + return message + return f"{message} (code {code})" + return str(error or "Unknown MCP error") + + +def _status_detail_from_exception( + exc: Exception, + *, + browser_url: str | None, + stderr_tail: list[str], +) -> str: + detail = str(exc).strip() or type(exc).__name__ + stderr_text = " ".join(line.strip() for line in stderr_tail[-4:] if line.strip()) + lower = f"{detail} {stderr_text}".lower() + hints: list[str] = [] + if "npx" in lower and ("not found" in lower or "no such file" in lower): + hints.append("Install Node.js/npm so `npx` is available locally.") + if "timed out" in lower or "timeout" in lower: + if browser_url: + hints.append("Confirm the remote debugging endpoint is reachable.") + else: + hints.append( + "Enable Chrome remote debugging at chrome://inspect/#remote-debugging " + "and allow the Chrome DevTools MCP connection prompt." + ) + if "inspect/#remote-debugging" not in lower and browser_url is None: + hints.append( + "Chrome 144+ must have remote debugging enabled at chrome://inspect/#remote-debugging." + ) + if stderr_text: + detail = f"{detail} stderr: {stderr_text}" + if hints: + detail = f"{detail} {' '.join(hints)}" + return detail.strip() + + +class ChromeMcpManager: + def __init__( + self, + *, + enabled: bool, + auto_connect: bool, + browser_url: str | None, + channel: str, + connect_timeout_sec: int, + rpc_timeout_sec: int, + ) -> None: + self.enabled = bool(enabled) + self.auto_connect = bool(auto_connect) + self.browser_url = normalize_chrome_mcp_browser_url(browser_url) + self.channel = normalize_chrome_mcp_channel(channel or CHROME_MCP_DEFAULT_CHANNEL) + self.connect_timeout_sec = max(1, int(connect_timeout_sec)) + self.rpc_timeout_sec = max(1, int(rpc_timeout_sec)) + self._lock = threading.RLock() + self._proc: subprocess.Popen[str] | None = None + self._reader_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + self._pending: dict[int, _PendingRequest] = {} + self._next_id = 1 + self._tools: list[ChromeMcpToolDef] = [] + self._last_refresh_at: float | None = None + self._status = ChromeMcpStatus( + status="disabled" if not self.enabled else "ready", + detail=( + "Chrome DevTools MCP is disabled." + if not self.enabled + else "Chrome DevTools MCP will initialize on the next solve." + ), + tool_count=0, + ) + self._stderr_tail: list[str] = [] + + def status_snapshot(self) -> ChromeMcpStatus: + with self._lock: + return ChromeMcpStatus( + status=self._status.status, + detail=self._status.detail, + tool_count=self._status.tool_count, + last_refresh_at=self._status.last_refresh_at, + ) + + def ensure_connected(self) -> None: + if not self.enabled: + with self._lock: + self._status = ChromeMcpStatus( + status="disabled", + detail="Chrome DevTools MCP is disabled.", + tool_count=len(self._tools), + last_refresh_at=self._last_refresh_at, + ) + return + with self._lock: + if self._proc is not None and self._proc.poll() is None and self._reader_thread is not None: + return + if not self.browser_url and not self.auto_connect: + detail = ( + "Chrome DevTools MCP is enabled but cannot attach: set " + "`chrome_mcp_browser_url` or enable `chrome_mcp_auto_connect`." + ) + self._status = ChromeMcpStatus( + status="unavailable", + detail=detail, + tool_count=len(self._tools), + last_refresh_at=self._last_refresh_at, + ) + raise ChromeMcpError(detail) + self._start_process_locked() + try: + self._initialize_handshake() + except Exception as exc: + detail = _status_detail_from_exception( + exc, + browser_url=self.browser_url, + stderr_tail=self._stderr_tail, + ) + with self._lock: + self._status = ChromeMcpStatus( + status="unavailable", + detail=detail, + tool_count=len(self._tools), + last_refresh_at=self._last_refresh_at, + ) + self.shutdown() + raise ChromeMcpError(detail) from exc + + def list_tools(self, *, force_refresh: bool = False) -> list[ChromeMcpToolDef]: + if not self.enabled: + return [] + self.ensure_connected() + with self._lock: + if self._tools and not force_refresh: + return list(self._tools) + tools: list[ChromeMcpToolDef] = [] + cursor: str | None = None + while True: + params: dict[str, Any] = {} + if cursor: + params["cursor"] = cursor + result = self._request_with_reconnect( + "tools/list", + params=params, + timeout_sec=self.rpc_timeout_sec, + ) + raw_tools = result.get("tools") + if isinstance(raw_tools, list): + for item in raw_tools: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + description = str(item.get("description") or "").strip() + parameters = item.get("inputSchema") + if not isinstance(parameters, dict): + parameters = {"type": "object", "properties": {}, "required": []} + tools.append( + ChromeMcpToolDef( + name=name, + description=description, + parameters=parameters, + ) + ) + raw_cursor = result.get("nextCursor") + cursor = str(raw_cursor).strip() if raw_cursor else None + if not cursor: + break + now = time.time() + with self._lock: + self._tools = tools + self._last_refresh_at = now + self._status = ChromeMcpStatus( + status="ready", + detail=( + f"Chrome DevTools MCP ready with {len(tools)} tool(s) " + f"via {'browser_url' if self.browser_url else 'auto-connect'}." + ), + tool_count=len(tools), + last_refresh_at=now, + ) + return list(self._tools) + + def call_tool(self, name: str, arguments: dict[str, Any]) -> ChromeMcpCallResult: + if not self.enabled: + raise ChromeMcpError("Chrome DevTools MCP is disabled.") + self.ensure_connected() + result = self._request_with_reconnect( + "tools/call", + params={"name": name, "arguments": arguments}, + timeout_sec=self.rpc_timeout_sec, + ) + return self._parse_call_result(result) + + def shutdown(self) -> None: + with self._lock: + self._shutdown_locked() + + def _request_with_reconnect( + self, + method: str, + *, + params: dict[str, Any], + timeout_sec: int, + ) -> dict[str, Any]: + last_error: Exception | None = None + for attempt in range(2): + try: + return self._request(method, params=params, timeout_sec=timeout_sec) + except Exception as exc: + last_error = exc + with self._lock: + self._shutdown_locked() + self._status = ChromeMcpStatus( + status="unavailable", + detail=_status_detail_from_exception( + exc, + browser_url=self.browser_url, + stderr_tail=self._stderr_tail, + ), + tool_count=len(self._tools), + last_refresh_at=self._last_refresh_at, + ) + if attempt == 0: + self.ensure_connected() + continue + break + raise ChromeMcpError(str(last_error or "Chrome DevTools MCP request failed")) + + def _initialize_handshake(self) -> None: + init_params = { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "openplanter-agent", "version": "1.0"}, + } + self._request("initialize", params=init_params, timeout_sec=self.connect_timeout_sec) + self._notify("notifications/initialized", {}) + + def _request( + self, + method: str, + *, + params: dict[str, Any], + timeout_sec: int, + ) -> dict[str, Any]: + with self._lock: + proc = self._proc + if proc is None or proc.poll() is not None or proc.stdin is None: + raise ChromeMcpError("Chrome DevTools MCP process is not running.") + request_id = self._next_id + self._next_id += 1 + pending = _PendingRequest(event=threading.Event()) + self._pending[request_id] = pending + payload = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + try: + proc.stdin.write(json.dumps(payload, ensure_ascii=True) + "\n") + proc.stdin.flush() + except Exception as exc: + self._pending.pop(request_id, None) + raise ChromeMcpError(f"Failed to send MCP request {method}: {exc}") from exc + if not pending.event.wait(timeout_sec): + with self._lock: + self._pending.pop(request_id, None) + raise ChromeMcpError(f"Timed out waiting for Chrome DevTools MCP {method} response.") + if pending.error is not None: + raise ChromeMcpError(str(pending.error)) + return pending.result or {} + + def _notify(self, method: str, params: dict[str, Any]) -> None: + with self._lock: + proc = self._proc + if proc is None or proc.poll() is not None or proc.stdin is None: + raise ChromeMcpError("Chrome DevTools MCP process is not running.") + payload = {"jsonrpc": "2.0", "method": method, "params": params} + proc.stdin.write(json.dumps(payload, ensure_ascii=True) + "\n") + proc.stdin.flush() + + def _start_process_locked(self) -> None: + self._shutdown_locked() + command = _env_text("OPENPLANTER_CHROME_MCP_COMMAND", "npx") + if shutil.which(command) is None: + raise ChromeMcpError(f"`{command}` is not installed or not on PATH.") + package = _env_text("OPENPLANTER_CHROME_MCP_PACKAGE", "chrome-devtools-mcp@latest") + args = [command, "-y", package] + if self.browser_url: + args.append(f"--browserUrl={self.browser_url}") + else: + args.append("--autoConnect") + args.append(f"--channel={self.channel}") + extra_args = (os.getenv("OPENPLANTER_CHROME_MCP_EXTRA_ARGS") or "").strip() + if extra_args: + args.extend(shlex.split(extra_args)) + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + start_new_session=True, + ) + self._reader_thread = threading.Thread( + target=self._reader_loop, + name="openplanter-chrome-mcp-reader", + daemon=True, + ) + self._stderr_thread = threading.Thread( + target=self._stderr_loop, + name="openplanter-chrome-mcp-stderr", + daemon=True, + ) + self._reader_thread.start() + self._stderr_thread.start() + + def _reader_loop(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + for raw_line in proc.stdout: + line = raw_line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict): + continue + request_id = payload.get("id") + if not isinstance(request_id, int): + continue + with self._lock: + pending = self._pending.pop(request_id, None) + if pending is None: + continue + if "error" in payload: + pending.error = ChromeMcpError(_format_protocol_error(payload.get("error"))) + else: + result = payload.get("result") + pending.result = result if isinstance(result, dict) else {} + pending.event.set() + finally: + exit_code = proc.poll() + error = ChromeMcpError( + f"Chrome DevTools MCP process exited unexpectedly" + + (f" with code {exit_code}." if exit_code is not None else ".") + ) + with self._lock: + pending = list(self._pending.values()) + self._pending.clear() + for item in pending: + item.error = error + item.event.set() + + def _stderr_loop(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + for raw_line in proc.stderr: + line = raw_line.strip() + if not line: + continue + with self._lock: + self._stderr_tail.append(line) + self._stderr_tail = self._stderr_tail[-20:] + + def _shutdown_locked(self) -> None: + proc = self._proc + self._proc = None + self._reader_thread = None + self._stderr_thread = None + pending = list(self._pending.values()) + self._pending.clear() + for item in pending: + item.error = ChromeMcpError("Chrome DevTools MCP shut down before responding.") + item.event.set() + if proc is None: + return + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + def _parse_call_result(self, result: dict[str, Any]) -> ChromeMcpCallResult: + content_parts: list[str] = [] + image: ChromeMcpImage | None = None + raw_content = result.get("content") + if isinstance(raw_content, list): + for item in raw_content: + if isinstance(item, str): + if item.strip(): + content_parts.append(item.strip()) + continue + if not isinstance(item, dict): + continue + item_type = str(item.get("type") or "").strip().lower() + if item_type == "text": + text = item.get("text") + if isinstance(text, str) and text.strip(): + content_parts.append(text.strip()) + continue + if item_type == "image": + data = item.get("data") + media_type = item.get("mimeType") or item.get("mediaType") + if ( + image is None + and isinstance(data, str) + and data.strip() + and isinstance(media_type, str) + and media_type.strip() + ): + image = ChromeMcpImage( + base64_data=data.strip(), + media_type=media_type.strip(), + ) + media_text = media_type.strip() if isinstance(media_type, str) else "image" + content_parts.append(f"[{media_text} attached]") + continue + uri = item.get("uri") or item.get("url") + if isinstance(uri, str) and uri.strip(): + label = str(item.get("name") or item_type or "resource").strip() + content_parts.append(f"{label}: {uri.strip()}") + structured = result.get("structuredContent") + if not content_parts and structured is not None: + try: + content_parts.append(json.dumps(structured, indent=2, ensure_ascii=True)) + except TypeError: + content_parts.append(str(structured)) + content = "\n".join(part for part in content_parts if part).strip() + if not content: + content = "Chrome DevTools MCP tool completed with no textual output." + is_error = bool(result.get("isError")) + if is_error: + content = f"Chrome DevTools MCP tool error: {content}" + return ChromeMcpCallResult(content=content, is_error=is_error, image=image) + + +_SHARED_MANAGERS: dict[tuple[Any, ...], ChromeMcpManager] = {} +_SHARED_LOCK = threading.Lock() + + +def acquire_shared_manager( + *, + enabled: bool, + auto_connect: bool, + browser_url: str | None, + channel: str, + connect_timeout_sec: int, + rpc_timeout_sec: int, +) -> ChromeMcpManager | None: + if not enabled: + return None + key = ( + bool(enabled), + bool(auto_connect), + normalize_chrome_mcp_browser_url(browser_url), + normalize_chrome_mcp_channel(channel), + max(1, int(connect_timeout_sec)), + max(1, int(rpc_timeout_sec)), + _env_text("OPENPLANTER_CHROME_MCP_COMMAND", "npx"), + _env_text("OPENPLANTER_CHROME_MCP_PACKAGE", "chrome-devtools-mcp@latest"), + (os.getenv("OPENPLANTER_CHROME_MCP_EXTRA_ARGS") or "").strip(), + ) + with _SHARED_LOCK: + manager = _SHARED_MANAGERS.get(key) + if manager is None: + manager = ChromeMcpManager( + enabled=enabled, + auto_connect=auto_connect, + browser_url=browser_url, + channel=channel, + connect_timeout_sec=connect_timeout_sec, + rpc_timeout_sec=rpc_timeout_sec, + ) + _SHARED_MANAGERS[key] = manager + return manager + + +def shutdown_all_shared_managers() -> None: + with _SHARED_LOCK: + managers = list(_SHARED_MANAGERS.values()) + _SHARED_MANAGERS.clear() + for manager in managers: + manager.shutdown() + + +atexit.register(shutdown_all_shared_managers) diff --git a/agent/config.py b/agent/config.py index 87e85264..8bdddc11 100644 --- a/agent/config.py +++ b/agent/config.py @@ -10,6 +10,10 @@ MISTRAL_TRANSCRIPTION_CHUNK_OVERLAP_SECONDS = 2.0 MISTRAL_TRANSCRIPTION_MAX_CHUNKS = 48 MISTRAL_TRANSCRIPTION_REQUEST_TIMEOUT_SEC = 180 +CHROME_MCP_DEFAULT_CHANNEL = "stable" +CHROME_MCP_CONNECT_TIMEOUT_SEC = 15 +CHROME_MCP_RPC_TIMEOUT_SEC = 45 +VALID_CHROME_MCP_CHANNELS: set[str] = {"stable", "beta", "dev", "canary"} PROVIDER_DEFAULT_MODELS: dict[str, str] = { "openai": "gpt-5.2", @@ -20,6 +24,25 @@ } +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def normalize_chrome_mcp_channel(value: str | None) -> str: + cleaned = (value or "").strip().lower() + if cleaned in VALID_CHROME_MCP_CHANNELS: + return cleaned + return CHROME_MCP_DEFAULT_CHANNEL + + +def normalize_chrome_mcp_browser_url(value: str | None) -> str | None: + cleaned = (value or "").strip() + return cleaned or None + + @dataclass(slots=True) class AgentConfig: workspace: Path @@ -52,6 +75,12 @@ class AgentConfig: mistral_transcription_request_timeout_sec: int = ( MISTRAL_TRANSCRIPTION_REQUEST_TIMEOUT_SEC ) + chrome_mcp_enabled: bool = False + chrome_mcp_auto_connect: bool = True + chrome_mcp_browser_url: str | None = None + chrome_mcp_channel: str = CHROME_MCP_DEFAULT_CHANNEL + chrome_mcp_connect_timeout_sec: int = CHROME_MCP_CONNECT_TIMEOUT_SEC + chrome_mcp_rpc_timeout_sec: int = CHROME_MCP_RPC_TIMEOUT_SEC max_depth: int = 4 max_steps_per_call: int = 100 budget_extension_enabled: bool = True @@ -99,9 +128,9 @@ def from_env(cls, workspace: str | Path) -> "AgentConfig": "OPENPLANTER_BASE_URL", "https://api.openai.com/v1", ) - budget_extension_enabled = ( - os.getenv("OPENPLANTER_BUDGET_EXTENSION_ENABLED", "true").strip().lower() - in {"1", "true", "yes"} + budget_extension_enabled = _env_bool( + "OPENPLANTER_BUDGET_EXTENSION_ENABLED", + True, ) budget_extension_block_steps = max( 1, @@ -169,6 +198,32 @@ def from_env(cls, workspace: str | Path) -> "AgentConfig": str(MISTRAL_TRANSCRIPTION_REQUEST_TIMEOUT_SEC), ) ), + chrome_mcp_enabled=_env_bool("OPENPLANTER_CHROME_MCP_ENABLED", False), + chrome_mcp_auto_connect=_env_bool("OPENPLANTER_CHROME_MCP_AUTO_CONNECT", True), + chrome_mcp_browser_url=normalize_chrome_mcp_browser_url( + os.getenv("OPENPLANTER_CHROME_MCP_BROWSER_URL") + ), + chrome_mcp_channel=normalize_chrome_mcp_channel( + os.getenv("OPENPLANTER_CHROME_MCP_CHANNEL") + ), + chrome_mcp_connect_timeout_sec=max( + 1, + int( + os.getenv( + "OPENPLANTER_CHROME_MCP_CONNECT_TIMEOUT_SEC", + str(CHROME_MCP_CONNECT_TIMEOUT_SEC), + ) + ), + ), + chrome_mcp_rpc_timeout_sec=max( + 1, + int( + os.getenv( + "OPENPLANTER_CHROME_MCP_RPC_TIMEOUT_SEC", + str(CHROME_MCP_RPC_TIMEOUT_SEC), + ) + ), + ), max_depth=int(os.getenv("OPENPLANTER_MAX_DEPTH", "4")), max_steps_per_call=int(os.getenv("OPENPLANTER_MAX_STEPS", "100")), budget_extension_enabled=budget_extension_enabled, @@ -194,10 +249,10 @@ def from_env(cls, workspace: str | Path) -> "AgentConfig": rate_limit_retry_after_cap_sec=float( os.getenv("OPENPLANTER_RATE_LIMIT_RETRY_AFTER_CAP_SEC", "120.0") ), - recursive=os.getenv("OPENPLANTER_RECURSIVE", "true").strip().lower() in ("1", "true", "yes"), + recursive=_env_bool("OPENPLANTER_RECURSIVE", True), min_subtask_depth=int(os.getenv("OPENPLANTER_MIN_SUBTASK_DEPTH", "0")), - acceptance_criteria=os.getenv("OPENPLANTER_ACCEPTANCE_CRITERIA", "true").strip().lower() in ("1", "true", "yes"), + acceptance_criteria=_env_bool("OPENPLANTER_ACCEPTANCE_CRITERIA", True), max_plan_chars=int(os.getenv("OPENPLANTER_MAX_PLAN_CHARS", "40000")), max_turn_summaries=int(os.getenv("OPENPLANTER_MAX_TURN_SUMMARIES", "50")), - demo=os.getenv("OPENPLANTER_DEMO", "").strip().lower() in ("1", "true", "yes"), + demo=_env_bool("OPENPLANTER_DEMO", False), ) diff --git a/agent/engine.py b/agent/engine.py index 1e4f4de1..4ff67441 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -431,10 +431,22 @@ def __post_init__(self) -> None: acceptance_criteria=self.config.acceptance_criteria, demo=self.config.demo, ) + self._set_model_tool_defs(self.model, include_subtask=self.config.recursive) + + def _build_tool_defs(self, *, include_subtask: bool) -> list[dict[str, Any]]: ac = self.config.acceptance_criteria - tool_defs = get_tool_definitions(include_subtask=self.config.recursive, include_acceptance_criteria=ac) - if hasattr(self.model, "tool_defs"): - self.model.tool_defs = tool_defs + dynamic_defs = self.tools.get_chrome_mcp_tool_defs() + return get_tool_definitions( + include_subtask=include_subtask, + include_acceptance_criteria=ac, + dynamic_defs=dynamic_defs, + ) + + def _set_model_tool_defs(self, model: BaseModel, *, include_subtask: bool) -> list[dict[str, Any]]: + tool_defs = self._build_tool_defs(include_subtask=include_subtask) + if hasattr(model, "tool_defs"): + model.tool_defs = tool_defs + return tool_defs def cancel(self) -> None: """Signal the engine to stop after the current model call or tool.""" @@ -462,6 +474,7 @@ def solve_with_context( self._shell_command_counts.clear() active_context = context if context is not None else ExternalContext() deadline = (time.monotonic() + self.config.max_solve_seconds) if self.config.max_solve_seconds > 0 else 0 + self._set_model_tool_defs(self.model, include_subtask=self.config.recursive) try: result = self._solve_recursive( objective=objective.strip(), @@ -1491,10 +1504,10 @@ def _apply_tool_call( # Give executor full tools (no subtask, no execute). _saved_defs = None if exec_model and hasattr(exec_model, "tool_defs"): - exec_model.tool_defs = get_tool_definitions(include_subtask=False, include_acceptance_criteria=self.config.acceptance_criteria) + exec_model.tool_defs = self._build_tool_defs(include_subtask=False) elif exec_model is None and hasattr(cur, "tool_defs"): _saved_defs = cur.tool_defs - cur.tool_defs = get_tool_definitions(include_subtask=False, include_acceptance_criteria=self.config.acceptance_criteria) + cur.tool_defs = self._build_tool_defs(include_subtask=False) self._emit(f"[d{depth}] >> executing leaf: {objective}", on_event) child_logger = ( @@ -1534,6 +1547,15 @@ def _apply_tool_call( limit = int(args.get("limit", 100) or 100) return False, self._read_artifact(aid, offset, limit) + dynamic_result = self.tools.try_execute_dynamic_tool(name, args) + if dynamic_result is not None: + if dynamic_result.image is not None: + self._pending_image.data = ( + dynamic_result.image.base64_data, + dynamic_result.image.media_type, + ) + return False, dynamic_result.content + return False, f"Unknown action type: {name}" # ------------------------------------------------------------------ diff --git a/agent/settings.py b/agent/settings.py index ec2835ee..a312dac7 100644 --- a/agent/settings.py +++ b/agent/settings.py @@ -6,6 +6,7 @@ VALID_REASONING_EFFORTS: set[str] = {"low", "medium", "high"} +VALID_CHROME_MCP_CHANNELS: set[str] = {"stable", "beta", "dev", "canary"} def normalize_reasoning_effort(value: str | None) -> str | None: @@ -22,6 +23,35 @@ def normalize_reasoning_effort(value: str | None) -> str | None: return cleaned +def normalize_bool(value: bool | str | None) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + cleaned = value.strip().lower() + if not cleaned: + return None + if cleaned in {"1", "true", "yes", "on"}: + return True + if cleaned in {"0", "false", "no", "off"}: + return False + raise ValueError(f"Invalid boolean value '{value}'.") + + +def normalize_chrome_mcp_channel(value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip().lower() + if not cleaned: + return None + if cleaned not in VALID_CHROME_MCP_CHANNELS: + raise ValueError( + f"Invalid Chrome MCP channel '{value}'. Expected one of: " + f"{', '.join(sorted(VALID_CHROME_MCP_CHANNELS))}" + ) + return cleaned + + @dataclass(slots=True) class PersistentSettings: default_model: str | None = None @@ -31,6 +61,12 @@ class PersistentSettings: default_model_openrouter: str | None = None default_model_cerebras: str | None = None default_model_ollama: str | None = None + chrome_mcp_enabled: bool | None = None + chrome_mcp_auto_connect: bool | None = None + chrome_mcp_browser_url: str | None = None + chrome_mcp_channel: str | None = None + chrome_mcp_connect_timeout_sec: int | None = None + chrome_mcp_rpc_timeout_sec: int | None = None def default_model_for_provider(self, provider: str) -> str | None: per_provider = { @@ -56,6 +92,20 @@ def normalized(self) -> "PersistentSettings": default_model_openrouter=(self.default_model_openrouter or "").strip() or None, default_model_cerebras=(self.default_model_cerebras or "").strip() or None, default_model_ollama=(self.default_model_ollama or "").strip() or None, + chrome_mcp_enabled=normalize_bool(self.chrome_mcp_enabled), + chrome_mcp_auto_connect=normalize_bool(self.chrome_mcp_auto_connect), + chrome_mcp_browser_url=(self.chrome_mcp_browser_url or "").strip() or None, + chrome_mcp_channel=normalize_chrome_mcp_channel(self.chrome_mcp_channel), + chrome_mcp_connect_timeout_sec=( + max(1, int(self.chrome_mcp_connect_timeout_sec)) + if self.chrome_mcp_connect_timeout_sec is not None + else None + ), + chrome_mcp_rpc_timeout_sec=( + max(1, int(self.chrome_mcp_rpc_timeout_sec)) + if self.chrome_mcp_rpc_timeout_sec is not None + else None + ), ) def to_json(self) -> dict[str, str]: @@ -74,6 +124,18 @@ def to_json(self) -> dict[str, str]: payload["default_model_cerebras"] = self.default_model_cerebras if self.default_model_ollama: payload["default_model_ollama"] = self.default_model_ollama + if self.chrome_mcp_enabled is not None: + payload["chrome_mcp_enabled"] = self.chrome_mcp_enabled + if self.chrome_mcp_auto_connect is not None: + payload["chrome_mcp_auto_connect"] = self.chrome_mcp_auto_connect + if self.chrome_mcp_browser_url: + payload["chrome_mcp_browser_url"] = self.chrome_mcp_browser_url + if self.chrome_mcp_channel: + payload["chrome_mcp_channel"] = self.chrome_mcp_channel + if self.chrome_mcp_connect_timeout_sec is not None: + payload["chrome_mcp_connect_timeout_sec"] = self.chrome_mcp_connect_timeout_sec + if self.chrome_mcp_rpc_timeout_sec is not None: + payload["chrome_mcp_rpc_timeout_sec"] = self.chrome_mcp_rpc_timeout_sec return payload @classmethod @@ -90,6 +152,20 @@ def from_json(cls, payload: dict | None) -> "PersistentSettings": default_model_openrouter=(str(payload.get("default_model_openrouter", "")).strip() or None), default_model_cerebras=(str(payload.get("default_model_cerebras", "")).strip() or None), default_model_ollama=(str(payload.get("default_model_ollama", "")).strip() or None), + chrome_mcp_enabled=payload.get("chrome_mcp_enabled"), + chrome_mcp_auto_connect=payload.get("chrome_mcp_auto_connect"), + chrome_mcp_browser_url=(str(payload.get("chrome_mcp_browser_url", "")).strip() or None), + chrome_mcp_channel=(str(payload.get("chrome_mcp_channel", "")).strip() or None), + chrome_mcp_connect_timeout_sec=( + int(payload["chrome_mcp_connect_timeout_sec"]) + if payload.get("chrome_mcp_connect_timeout_sec") is not None + else None + ), + chrome_mcp_rpc_timeout_sec=( + int(payload["chrome_mcp_rpc_timeout_sec"]) + if payload.get("chrome_mcp_rpc_timeout_sec") is not None + else None + ), ).normalized() diff --git a/agent/tool_defs.py b/agent/tool_defs.py index 90fb3ba5..21df98d9 100644 --- a/agent/tool_defs.py +++ b/agent/tool_defs.py @@ -488,6 +488,34 @@ _DELEGATION_TOOLS = {"subtask", "execute", "list_artifacts", "read_artifact"} +def _merge_dynamic_definitions( + defs: list[dict[str, Any]], + dynamic_defs: list[dict[str, Any]] | None, +) -> list[dict[str, Any]]: + if not dynamic_defs: + return defs + merged = list(defs) + seen = {str(item.get("name", "")).strip() for item in defs} + for item in dynamic_defs: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip() + if not name or name in seen: + continue + parameters = item.get("parameters") + if not isinstance(parameters, dict): + continue + merged.append( + { + "name": name, + "description": str(item.get("description", "") or ""), + "parameters": parameters, + } + ) + seen.add(name) + return merged + + def _strip_acceptance_criteria(defs: list[dict[str, Any]]) -> list[dict[str, Any]]: """Remove acceptance_criteria property from subtask/execute schemas.""" import copy @@ -507,6 +535,7 @@ def get_tool_definitions( include_subtask: bool = True, include_artifacts: bool = False, include_acceptance_criteria: bool = False, + dynamic_defs: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """Return tool definitions based on mode. @@ -523,6 +552,8 @@ def get_tool_definitions( if include_artifacts: defs += [d for d in TOOL_DEFINITIONS if d["name"] in _ARTIFACT_TOOLS] + defs = _merge_dynamic_definitions(defs, dynamic_defs) + if not include_acceptance_criteria: defs = _strip_acceptance_criteria(defs) return defs diff --git a/agent/tools.py b/agent/tools.py index 8310c5be..fa228c3e 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -24,6 +24,12 @@ _MAX_WALK_ENTRIES = 50_000 +from .chrome_mcp import ( + ChromeMcpCallResult, + ChromeMcpError, + ChromeMcpStatus, + acquire_shared_manager, +) from .patching import ( AddFileOp, DeleteFileOp, @@ -69,6 +75,12 @@ class WorkspaceTools: mistral_transcription_chunk_overlap_seconds: float = 2.0 mistral_transcription_max_chunks: int = 48 mistral_transcription_request_timeout_sec: int = 180 + chrome_mcp_enabled: bool = False + chrome_mcp_auto_connect: bool = True + chrome_mcp_browser_url: str | None = None + chrome_mcp_channel: str = "stable" + chrome_mcp_connect_timeout_sec: int = 15 + chrome_mcp_rpc_timeout_sec: int = 45 def __post_init__(self) -> None: self.root = self.root.expanduser().resolve() @@ -83,6 +95,14 @@ def __post_init__(self) -> None: self._parallel_write_claims: dict[str, dict[Path, str]] = {} self._parallel_lock = threading.Lock() self._scope_local = threading.local() + self._chrome_mcp = acquire_shared_manager( + enabled=self.chrome_mcp_enabled, + auto_connect=self.chrome_mcp_auto_connect, + browser_url=self.chrome_mcp_browser_url, + channel=self.chrome_mcp_channel, + connect_timeout_sec=self.chrome_mcp_connect_timeout_sec, + rpc_timeout_sec=self.chrome_mcp_rpc_timeout_sec, + ) def _clip(self, text: str, max_chars: int) -> str: if len(text) <= max_chars: @@ -274,6 +294,49 @@ def cleanup_bg_jobs(self) -> None: pass self._bg_jobs.clear() + def chrome_mcp_status(self) -> ChromeMcpStatus: + if not self.chrome_mcp_enabled or self._chrome_mcp is None: + return ChromeMcpStatus( + status="disabled", + detail="Chrome DevTools MCP is disabled.", + ) + return self._chrome_mcp.status_snapshot() + + def get_chrome_mcp_tool_defs(self, *, force_refresh: bool = False) -> list[dict[str, Any]]: + if not self.chrome_mcp_enabled or self._chrome_mcp is None: + return [] + try: + return [ + tool.as_tool_definition() + for tool in self._chrome_mcp.list_tools(force_refresh=force_refresh) + ] + except ChromeMcpError: + return [] + + def try_execute_dynamic_tool( + self, + name: str, + arguments: dict[str, Any], + ) -> ChromeMcpCallResult | None: + if not self.chrome_mcp_enabled or self._chrome_mcp is None: + return None + try: + known_names = {tool.name for tool in self._chrome_mcp.list_tools()} + except ChromeMcpError as exc: + return ChromeMcpCallResult( + content=f"Chrome DevTools MCP unavailable: {exc}", + is_error=True, + ) + if name not in known_names: + return None + try: + return self._chrome_mcp.call_tool(name, arguments) + except ChromeMcpError as exc: + return ChromeMcpCallResult( + content=f"Chrome DevTools MCP unavailable: {exc}", + is_error=True, + ) + def list_files(self, glob: str | None = None) -> str: lines: list[str] if shutil.which("rg"): diff --git a/agent/tui.py b/agent/tui.py index 3217f3dc..71a4378f 100644 --- a/agent/tui.py +++ b/agent/tui.py @@ -15,7 +15,16 @@ from .settings import SettingsStore -SLASH_COMMANDS: list[str] = ["/quit", "/exit", "/help", "/status", "/clear", "/model", "/reasoning"] +SLASH_COMMANDS: list[str] = [ + "/quit", + "/exit", + "/help", + "/status", + "/clear", + "/model", + "/reasoning", + "/chrome", +] def _queue_prompt_style(): @@ -106,6 +115,7 @@ def _build_splash() -> str: " /model --save Switch and persist as default", " /model list [all] List available models", " /reasoning [low|medium|high|off] Change reasoning effort", + " /chrome status|on|off|auto|url |channel [--save]", " /status /clear /quit /exit /help", ] @@ -350,6 +360,90 @@ def _get_mode_label(cfg: AgentConfig) -> str: return "flat" +def _format_chrome_status(ctx: ChatContext) -> list[str]: + status = ctx.runtime.engine.tools.chrome_mcp_status() + attach_mode = ( + f"browser_url={ctx.cfg.chrome_mcp_browser_url}" + if ctx.cfg.chrome_mcp_browser_url + else ("auto-connect" if ctx.cfg.chrome_mcp_auto_connect else "manual-disabled") + ) + lines = [ + ( + "Chrome MCP: " + f"enabled={ctx.cfg.chrome_mcp_enabled} | attach={attach_mode} | " + f"channel={ctx.cfg.chrome_mcp_channel}" + ), + f"Runtime status: {status.status} | {status.detail}", + ] + if status.tool_count: + lines.append(f"Discovered Chrome tools: {status.tool_count}") + return lines + + +def handle_chrome_command(args: str, ctx: ChatContext) -> list[str]: + from .builder import build_engine + + parts = [part for part in args.strip().split() if part] + save = False + if "--save" in parts: + save = True + parts = [part for part in parts if part != "--save"] + + if not parts or parts[0] == "status": + lines = _format_chrome_status(ctx) + if not parts: + lines.append( + "Usage: /chrome status|on|off|auto|url |channel [--save]" + ) + return lines + + action = parts[0].lower() + if action == "on": + ctx.cfg.chrome_mcp_enabled = True + elif action == "off": + ctx.cfg.chrome_mcp_enabled = False + elif action == "auto": + ctx.cfg.chrome_mcp_enabled = True + ctx.cfg.chrome_mcp_auto_connect = True + ctx.cfg.chrome_mcp_browser_url = None + elif action == "url": + if len(parts) < 2: + return ["Usage: /chrome url [--save]"] + ctx.cfg.chrome_mcp_enabled = True + ctx.cfg.chrome_mcp_auto_connect = False + ctx.cfg.chrome_mcp_browser_url = parts[1].strip() or None + elif action == "channel": + if len(parts) < 2: + return ["Usage: /chrome channel [--save]"] + channel = parts[1].strip().lower() + if channel not in {"stable", "beta", "dev", "canary"}: + return [f"Invalid Chrome channel '{channel}'. Use: stable, beta, dev, canary"] + ctx.cfg.chrome_mcp_channel = channel + else: + return [ + f"Unknown /chrome action '{action}'.", + "Usage: /chrome status|on|off|auto|url |channel [--save]", + ] + + try: + ctx.runtime.engine = build_engine(ctx.cfg) + except ModelError as exc: + return [f"Failed to apply Chrome MCP change: {exc}"] + + lines = _format_chrome_status(ctx) + if save: + settings = ctx.settings_store.load() + settings.chrome_mcp_enabled = ctx.cfg.chrome_mcp_enabled + settings.chrome_mcp_auto_connect = ctx.cfg.chrome_mcp_auto_connect + settings.chrome_mcp_browser_url = ctx.cfg.chrome_mcp_browser_url + settings.chrome_mcp_channel = ctx.cfg.chrome_mcp_channel + settings.chrome_mcp_connect_timeout_sec = ctx.cfg.chrome_mcp_connect_timeout_sec + settings.chrome_mcp_rpc_timeout_sec = ctx.cfg.chrome_mcp_rpc_timeout_sec + ctx.settings_store.save(settings) + lines.append("Saved as workspace default.") + return lines + + def dispatch_slash_command( command: str, ctx: ChatContext, @@ -377,6 +471,8 @@ def dispatch_slash_command( ) else: emit(" Tokens: (none yet)") + for line in _format_chrome_status(ctx): + emit(f" {line}") return "handled" if command == "/clear": return "clear" @@ -392,6 +488,12 @@ def dispatch_slash_command( for line in lines: emit(line) return "handled" + if command.startswith("/chrome"): + cmd_args = command[len("/chrome"):].strip() + lines = handle_chrome_command(cmd_args, ctx) + for line in lines: + emit(line) + return "handled" return None diff --git a/openplanter-desktop/crates/op-core/src/config.rs b/openplanter-desktop/crates/op-core/src/config.rs index 2a92486c..4e522753 100644 --- a/openplanter-desktop/crates/op-core/src/config.rs +++ b/openplanter-desktop/crates/op-core/src/config.rs @@ -11,6 +11,9 @@ pub const MISTRAL_TRANSCRIPTION_CHUNK_MAX_SECONDS: i64 = 900; pub const MISTRAL_TRANSCRIPTION_CHUNK_OVERLAP_SECONDS: f64 = 2.0; pub const MISTRAL_TRANSCRIPTION_MAX_CHUNKS: i64 = 48; pub const MISTRAL_TRANSCRIPTION_REQUEST_TIMEOUT_SEC: i64 = 180; +pub const CHROME_MCP_DEFAULT_CHANNEL: &str = "stable"; +pub const CHROME_MCP_CONNECT_TIMEOUT_SEC: i64 = 15; +pub const CHROME_MCP_RPC_TIMEOUT_SEC: i64 = 45; /// Default model for each supported provider. pub static PROVIDER_DEFAULT_MODELS: LazyLock> = @@ -48,11 +51,27 @@ fn env_float(key: &str, default: f64) -> f64 { fn env_bool(key: &str, default: bool) -> bool { match env::var(key) { - Ok(v) => matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes"), + Ok(v) => matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on"), Err(_) => default, } } +pub fn normalize_chrome_mcp_channel(value: Option<&str>) -> String { + match value.unwrap_or_default().trim().to_lowercase().as_str() { + "beta" => "beta".to_string(), + "dev" => "dev".to_string(), + "canary" => "canary".to_string(), + _ => CHROME_MCP_DEFAULT_CHANNEL.to_string(), + } +} + +pub fn normalize_chrome_mcp_browser_url(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + /// Central configuration for the OpenPlanter agent. /// /// Mirrors the Python `AgentConfig` dataclass field-for-field. @@ -88,6 +107,12 @@ pub struct AgentConfig { pub mistral_transcription_chunk_overlap_seconds: f64, pub mistral_transcription_max_chunks: i64, pub mistral_transcription_request_timeout_sec: i64, + pub chrome_mcp_enabled: bool, + pub chrome_mcp_auto_connect: bool, + pub chrome_mcp_browser_url: Option, + pub chrome_mcp_channel: String, + pub chrome_mcp_connect_timeout_sec: i64, + pub chrome_mcp_rpc_timeout_sec: i64, // Limits pub max_depth: i64, @@ -147,6 +172,12 @@ impl Default for AgentConfig { MISTRAL_TRANSCRIPTION_CHUNK_OVERLAP_SECONDS, mistral_transcription_max_chunks: MISTRAL_TRANSCRIPTION_MAX_CHUNKS, mistral_transcription_request_timeout_sec: MISTRAL_TRANSCRIPTION_REQUEST_TIMEOUT_SEC, + chrome_mcp_enabled: false, + chrome_mcp_auto_connect: true, + chrome_mcp_browser_url: None, + chrome_mcp_channel: CHROME_MCP_DEFAULT_CHANNEL.into(), + chrome_mcp_connect_timeout_sec: CHROME_MCP_CONNECT_TIMEOUT_SEC, + chrome_mcp_rpc_timeout_sec: CHROME_MCP_RPC_TIMEOUT_SEC, max_depth: 4, max_steps_per_call: 100, budget_extension_enabled: true, @@ -284,6 +315,24 @@ impl AgentConfig { "OPENPLANTER_MISTRAL_TRANSCRIPTION_REQUEST_TIMEOUT_SEC", MISTRAL_TRANSCRIPTION_REQUEST_TIMEOUT_SEC, ), + chrome_mcp_enabled: env_bool("OPENPLANTER_CHROME_MCP_ENABLED", false), + chrome_mcp_auto_connect: env_bool("OPENPLANTER_CHROME_MCP_AUTO_CONNECT", true), + chrome_mcp_browser_url: normalize_chrome_mcp_browser_url( + env_opt("OPENPLANTER_CHROME_MCP_BROWSER_URL").as_deref(), + ), + chrome_mcp_channel: normalize_chrome_mcp_channel( + env_opt("OPENPLANTER_CHROME_MCP_CHANNEL").as_deref(), + ), + chrome_mcp_connect_timeout_sec: env_int( + "OPENPLANTER_CHROME_MCP_CONNECT_TIMEOUT_SEC", + CHROME_MCP_CONNECT_TIMEOUT_SEC, + ) + .max(1), + chrome_mcp_rpc_timeout_sec: env_int( + "OPENPLANTER_CHROME_MCP_RPC_TIMEOUT_SEC", + CHROME_MCP_RPC_TIMEOUT_SEC, + ) + .max(1), max_depth: env_int("OPENPLANTER_MAX_DEPTH", 4), max_steps_per_call: env_int("OPENPLANTER_MAX_STEPS", 100), budget_extension_enabled: env_bool("OPENPLANTER_BUDGET_EXTENSION_ENABLED", true), @@ -350,6 +399,12 @@ mod tests { assert_eq!(cfg.provider, "auto"); assert_eq!(cfg.model, "claude-opus-4-6"); assert_eq!(cfg.reasoning_effort, Some("high".into())); + assert!(!cfg.chrome_mcp_enabled); + assert!(cfg.chrome_mcp_auto_connect); + assert_eq!(cfg.chrome_mcp_browser_url, None); + assert_eq!(cfg.chrome_mcp_channel, CHROME_MCP_DEFAULT_CHANNEL); + assert_eq!(cfg.chrome_mcp_connect_timeout_sec, CHROME_MCP_CONNECT_TIMEOUT_SEC); + assert_eq!(cfg.chrome_mcp_rpc_timeout_sec, CHROME_MCP_RPC_TIMEOUT_SEC); assert_eq!(cfg.max_depth, 4); assert_eq!(cfg.max_steps_per_call, 100); assert!(cfg.budget_extension_enabled); @@ -382,6 +437,23 @@ mod tests { assert_eq!(PROVIDER_DEFAULT_MODELS.get("ollama"), Some(&"llama3.2")); } + #[test] + fn test_normalize_chrome_mcp_helpers() { + assert_eq!( + normalize_chrome_mcp_channel(Some("BETA")), + "beta".to_string() + ); + assert_eq!( + normalize_chrome_mcp_channel(Some("unexpected")), + CHROME_MCP_DEFAULT_CHANNEL.to_string() + ); + assert_eq!( + normalize_chrome_mcp_browser_url(Some(" http://127.0.0.1:9222 ")), + Some("http://127.0.0.1:9222".to_string()) + ); + assert_eq!(normalize_chrome_mcp_browser_url(Some(" ")), None); + } + /// Combined env-based test to avoid race conditions from parallel test execution. /// Tests both default and custom env var loading in sequence. #[test] @@ -398,6 +470,12 @@ mod tests { "OPENPLANTER_BUDGET_EXTENSION_ENABLED", "OPENPLANTER_BUDGET_EXTENSION_BLOCK_STEPS", "OPENPLANTER_BUDGET_EXTENSION_MAX_BLOCKS", + "OPENPLANTER_CHROME_MCP_ENABLED", + "OPENPLANTER_CHROME_MCP_AUTO_CONNECT", + "OPENPLANTER_CHROME_MCP_BROWSER_URL", + "OPENPLANTER_CHROME_MCP_CHANNEL", + "OPENPLANTER_CHROME_MCP_CONNECT_TIMEOUT_SEC", + "OPENPLANTER_CHROME_MCP_RPC_TIMEOUT_SEC", "OPENPLANTER_RECURSIVE", "OPENPLANTER_DEMO", "OPENPLANTER_RATE_LIMIT_MAX_RETRIES", @@ -423,6 +501,12 @@ mod tests { assert_eq!(cfg.provider, "auto"); assert_eq!(cfg.model, "claude-opus-4-6"); assert_eq!(cfg.reasoning_effort, Some("high".into())); + assert!(!cfg.chrome_mcp_enabled); + assert!(cfg.chrome_mcp_auto_connect); + assert_eq!(cfg.chrome_mcp_browser_url, None); + assert_eq!(cfg.chrome_mcp_channel, CHROME_MCP_DEFAULT_CHANNEL); + assert_eq!(cfg.chrome_mcp_connect_timeout_sec, CHROME_MCP_CONNECT_TIMEOUT_SEC); + assert_eq!(cfg.chrome_mcp_rpc_timeout_sec, CHROME_MCP_RPC_TIMEOUT_SEC); assert_eq!(cfg.max_depth, 4); assert!(cfg.budget_extension_enabled); assert_eq!(cfg.budget_extension_block_steps, 20); @@ -445,6 +529,12 @@ mod tests { env::set_var("OPENPLANTER_BUDGET_EXTENSION_ENABLED", "false"); env::set_var("OPENPLANTER_BUDGET_EXTENSION_BLOCK_STEPS", "9"); env::set_var("OPENPLANTER_BUDGET_EXTENSION_MAX_BLOCKS", "1"); + env::set_var("OPENPLANTER_CHROME_MCP_ENABLED", "true"); + env::set_var("OPENPLANTER_CHROME_MCP_AUTO_CONNECT", "false"); + env::set_var("OPENPLANTER_CHROME_MCP_BROWSER_URL", "http://127.0.0.1:9222"); + env::set_var("OPENPLANTER_CHROME_MCP_CHANNEL", "beta"); + env::set_var("OPENPLANTER_CHROME_MCP_CONNECT_TIMEOUT_SEC", "17"); + env::set_var("OPENPLANTER_CHROME_MCP_RPC_TIMEOUT_SEC", "52"); env::set_var("OPENPLANTER_RECURSIVE", "false"); env::set_var("OPENPLANTER_DEMO", "true"); env::set_var("OPENAI_API_KEY", "sk-test123"); @@ -462,6 +552,15 @@ mod tests { assert!(!cfg.budget_extension_enabled); assert_eq!(cfg.budget_extension_block_steps, 9); assert_eq!(cfg.budget_extension_max_blocks, 1); + assert!(cfg.chrome_mcp_enabled); + assert!(!cfg.chrome_mcp_auto_connect); + assert_eq!( + cfg.chrome_mcp_browser_url, + Some("http://127.0.0.1:9222".to_string()) + ); + assert_eq!(cfg.chrome_mcp_channel, "beta"); + assert_eq!(cfg.chrome_mcp_connect_timeout_sec, 17); + assert_eq!(cfg.chrome_mcp_rpc_timeout_sec, 52); assert_eq!(cfg.rate_limit_max_retries, 5); assert_eq!(cfg.rate_limit_backoff_base_sec, 2.5); assert_eq!(cfg.rate_limit_backoff_max_sec, 30.0); diff --git a/openplanter-desktop/crates/op-core/src/config_hydration.rs b/openplanter-desktop/crates/op-core/src/config_hydration.rs index 7bf5de7b..778db061 100644 --- a/openplanter-desktop/crates/op-core/src/config_hydration.rs +++ b/openplanter-desktop/crates/op-core/src/config_hydration.rs @@ -1,6 +1,8 @@ use std::env; -use crate::config::AgentConfig; +use crate::config::{ + AgentConfig, normalize_chrome_mcp_browser_url, normalize_chrome_mcp_channel, +}; use crate::credentials::CredentialBundle; use crate::settings::PersistentSettings; @@ -46,6 +48,41 @@ pub fn apply_settings_to_config(cfg: &mut AgentConfig, settings: &PersistentSett } } + if !has_env_value(&["OPENPLANTER_CHROME_MCP_ENABLED"]) { + if let Some(enabled) = settings.chrome_mcp_enabled { + cfg.chrome_mcp_enabled = enabled; + } + } + + if !has_env_value(&["OPENPLANTER_CHROME_MCP_AUTO_CONNECT"]) { + if let Some(auto_connect) = settings.chrome_mcp_auto_connect { + cfg.chrome_mcp_auto_connect = auto_connect; + } + } + + if !has_env_value(&["OPENPLANTER_CHROME_MCP_BROWSER_URL"]) { + cfg.chrome_mcp_browser_url = + normalize_chrome_mcp_browser_url(settings.chrome_mcp_browser_url.as_deref()); + } + + if !has_env_value(&["OPENPLANTER_CHROME_MCP_CHANNEL"]) { + if let Some(channel) = settings.chrome_mcp_channel.as_deref() { + cfg.chrome_mcp_channel = normalize_chrome_mcp_channel(Some(channel)); + } + } + + if !has_env_value(&["OPENPLANTER_CHROME_MCP_CONNECT_TIMEOUT_SEC"]) { + if let Some(timeout) = settings.chrome_mcp_connect_timeout_sec { + cfg.chrome_mcp_connect_timeout_sec = timeout.max(1); + } + } + + if !has_env_value(&["OPENPLANTER_CHROME_MCP_RPC_TIMEOUT_SEC"]) { + if let Some(timeout) = settings.chrome_mcp_rpc_timeout_sec { + cfg.chrome_mcp_rpc_timeout_sec = timeout.max(1); + } + } + if !has_env_value(&["OPENPLANTER_MODEL"]) { let saved_model = if cfg.provider == "auto" { settings.default_model.as_deref() diff --git a/openplanter-desktop/crates/op-core/src/engine/mod.rs b/openplanter-desktop/crates/op-core/src/engine/mod.rs index 4b6faa18..be16d40a 100644 --- a/openplanter-desktop/crates/op-core/src/engine/mod.rs +++ b/openplanter-desktop/crates/op-core/src/engine/mod.rs @@ -9,6 +9,7 @@ pub mod investigation_state; pub mod judge; use std::collections::HashSet; +use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; @@ -795,6 +796,26 @@ pub async fn solve_with_initial_context( emitter: &dyn SolveEmitter, cancel: CancellationToken, initial_context: Option, +) { + solve_with_initial_context_and_chrome_mcp( + objective, + config, + emitter, + cancel, + initial_context, + None, + ) + .await; +} + +/// Real solve flow with optional initial structured context and shared Chrome MCP manager. +pub async fn solve_with_initial_context_and_chrome_mcp( + objective: &str, + config: &AgentConfig, + emitter: &dyn SolveEmitter, + cancel: CancellationToken, + initial_context: Option, + chrome_mcp: Option>, ) { if config.demo { return demo_solve(objective, emitter, cancel).await; @@ -813,8 +834,21 @@ pub async fn solve_with_initial_context( emitter.emit_trace(&format!("Solving with {}/{}", provider, model.model_name())); // 2. Build tools and messages - let tool_defs = build_tool_defs(&provider); - let mut tools = WorkspaceTools::new(config); + let dynamic_tool_defs = if let Some(manager) = chrome_mcp.as_ref() { + match manager.list_tools(false).await { + Ok(defs) => defs, + Err(err) => { + emitter.emit_trace(&format!( + "[chrome-mcp] unavailable; continuing with built-in tools only: {err}" + )); + Vec::new() + } + } + } else { + Vec::new() + }; + let tool_defs = build_tool_defs(&provider, &dynamic_tool_defs); + let mut tools = WorkspaceTools::new(config, chrome_mcp); let system_prompt = build_system_prompt(config.recursive, config.acceptance_criteria, config.demo); diff --git a/openplanter-desktop/crates/op-core/src/events.rs b/openplanter-desktop/crates/op-core/src/events.rs index 625b6db1..ba925374 100644 --- a/openplanter-desktop/crates/op-core/src/events.rs +++ b/openplanter-desktop/crates/op-core/src/events.rs @@ -199,6 +199,14 @@ pub struct ConfigView { pub provider: String, pub model: String, pub reasoning_effort: Option, + pub chrome_mcp_enabled: bool, + pub chrome_mcp_auto_connect: bool, + pub chrome_mcp_browser_url: Option, + pub chrome_mcp_channel: String, + pub chrome_mcp_connect_timeout_sec: i64, + pub chrome_mcp_rpc_timeout_sec: i64, + pub chrome_mcp_status: String, + pub chrome_mcp_status_detail: String, pub workspace: String, pub session_id: Option, pub recursive: bool, @@ -213,6 +221,12 @@ pub struct PartialConfig { pub provider: Option, pub model: Option, pub reasoning_effort: Option, + pub chrome_mcp_enabled: Option, + pub chrome_mcp_auto_connect: Option, + pub chrome_mcp_browser_url: Option, + pub chrome_mcp_channel: Option, + pub chrome_mcp_connect_timeout_sec: Option, + pub chrome_mcp_rpc_timeout_sec: Option, } /// Model information for the model list. diff --git a/openplanter-desktop/crates/op-core/src/settings.rs b/openplanter-desktop/crates/op-core/src/settings.rs index 69fcd320..2ffb0c7c 100644 --- a/openplanter-desktop/crates/op-core/src/settings.rs +++ b/openplanter-desktop/crates/op-core/src/settings.rs @@ -1,9 +1,10 @@ -use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::config::{normalize_chrome_mcp_browser_url, normalize_chrome_mcp_channel}; + const VALID_REASONING_EFFORTS: &[&str] = &["low", "medium", "high"]; /// Normalize and validate a reasoning effort value. @@ -27,6 +28,20 @@ pub fn normalize_reasoning_effort(value: Option<&str>) -> Result, } } +pub fn normalize_bool(value: Option<&serde_json::Value>) -> Result, String> { + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Bool(value)) => Ok(Some(*value)), + Some(serde_json::Value::String(value)) => match value.trim().to_lowercase().as_str() { + "" => Ok(None), + "1" | "true" | "yes" | "on" => Ok(Some(true)), + "0" | "false" | "no" | "off" => Ok(Some(false)), + _ => Err(format!("Invalid boolean value '{}'.", value)), + }, + Some(other) => Err(format!("Invalid boolean value '{}'.", other)), + } +} + /// Persistent settings stored per workspace. /// /// Mirrors the Python `PersistentSettings` dataclass. @@ -39,6 +54,12 @@ pub struct PersistentSettings { pub default_model_openrouter: Option, pub default_model_cerebras: Option, pub default_model_ollama: Option, + pub chrome_mcp_enabled: Option, + pub chrome_mcp_auto_connect: Option, + pub chrome_mcp_browser_url: Option, + pub chrome_mcp_channel: Option, + pub chrome_mcp_connect_timeout_sec: Option, + pub chrome_mcp_rpc_timeout_sec: Option, } impl PersistentSettings { @@ -85,16 +106,27 @@ impl PersistentSettings { default_model_openrouter: trim_opt(&self.default_model_openrouter), default_model_cerebras: trim_opt(&self.default_model_cerebras), default_model_ollama: trim_opt(&self.default_model_ollama), + chrome_mcp_enabled: self.chrome_mcp_enabled, + chrome_mcp_auto_connect: self.chrome_mcp_auto_connect, + chrome_mcp_browser_url: normalize_chrome_mcp_browser_url( + self.chrome_mcp_browser_url.as_deref(), + ), + chrome_mcp_channel: self + .chrome_mcp_channel + .as_deref() + .map(|value| normalize_chrome_mcp_channel(Some(value))), + chrome_mcp_connect_timeout_sec: self.chrome_mcp_connect_timeout_sec.map(|value| value.max(1)), + chrome_mcp_rpc_timeout_sec: self.chrome_mcp_rpc_timeout_sec.map(|value| value.max(1)), }) } /// Serialize to JSON map, omitting `None` values. - pub fn to_json(&self) -> HashMap { - let mut payload = HashMap::new(); + pub fn to_json(&self) -> serde_json::Map { + let mut payload = serde_json::Map::new(); macro_rules! add { ($field:ident, $key:expr) => { if let Some(ref v) = self.$field { - payload.insert($key.to_string(), v.clone()); + payload.insert($key.to_string(), serde_json::json!(v)); } }; } @@ -105,6 +137,12 @@ impl PersistentSettings { add!(default_model_openrouter, "default_model_openrouter"); add!(default_model_cerebras, "default_model_cerebras"); add!(default_model_ollama, "default_model_ollama"); + add!(chrome_mcp_enabled, "chrome_mcp_enabled"); + add!(chrome_mcp_auto_connect, "chrome_mcp_auto_connect"); + add!(chrome_mcp_browser_url, "chrome_mcp_browser_url"); + add!(chrome_mcp_channel, "chrome_mcp_channel"); + add!(chrome_mcp_connect_timeout_sec, "chrome_mcp_connect_timeout_sec"); + add!(chrome_mcp_rpc_timeout_sec, "chrome_mcp_rpc_timeout_sec"); payload } @@ -130,6 +168,19 @@ impl PersistentSettings { default_model_openrouter: get_str(obj, "default_model_openrouter"), default_model_cerebras: get_str(obj, "default_model_cerebras"), default_model_ollama: get_str(obj, "default_model_ollama"), + chrome_mcp_enabled: normalize_bool(obj.get("chrome_mcp_enabled"))?, + chrome_mcp_auto_connect: normalize_bool(obj.get("chrome_mcp_auto_connect"))?, + chrome_mcp_browser_url: normalize_chrome_mcp_browser_url( + get_str(obj, "chrome_mcp_browser_url").as_deref(), + ), + chrome_mcp_channel: get_str(obj, "chrome_mcp_channel") + .map(|value| normalize_chrome_mcp_channel(Some(&value))), + chrome_mcp_connect_timeout_sec: obj + .get("chrome_mcp_connect_timeout_sec") + .and_then(|value| value.as_i64()), + chrome_mcp_rpc_timeout_sec: obj + .get("chrome_mcp_rpc_timeout_sec") + .and_then(|value| value.as_i64()), }; settings.normalized() } @@ -236,12 +287,27 @@ mod tests { let settings = PersistentSettings { default_model: Some("gpt-5.2".into()), default_reasoning_effort: Some("high".into()), + chrome_mcp_enabled: Some(true), + chrome_mcp_auto_connect: Some(false), + chrome_mcp_browser_url: Some("http://127.0.0.1:9222".into()), + chrome_mcp_channel: Some("beta".into()), + chrome_mcp_connect_timeout_sec: Some(21), + chrome_mcp_rpc_timeout_sec: Some(61), ..Default::default() }; store.save(&settings).unwrap(); let loaded = store.load(); assert_eq!(loaded.default_model, Some("gpt-5.2".into())); assert_eq!(loaded.default_reasoning_effort, Some("high".into())); + assert_eq!(loaded.chrome_mcp_enabled, Some(true)); + assert_eq!(loaded.chrome_mcp_auto_connect, Some(false)); + assert_eq!( + loaded.chrome_mcp_browser_url, + Some("http://127.0.0.1:9222".into()) + ); + assert_eq!(loaded.chrome_mcp_channel, Some("beta".into())); + assert_eq!(loaded.chrome_mcp_connect_timeout_sec, Some(21)); + assert_eq!(loaded.chrome_mcp_rpc_timeout_sec, Some(61)); } #[test] @@ -270,6 +336,12 @@ mod tests { default_model: Some("gpt-5.2".into()), default_reasoning_effort: Some("high".into()), default_model_openai: Some("gpt-5.2".into()), + chrome_mcp_enabled: Some(true), + chrome_mcp_auto_connect: Some(false), + chrome_mcp_browser_url: Some("http://127.0.0.1:9222".into()), + chrome_mcp_channel: Some("beta".into()), + chrome_mcp_connect_timeout_sec: Some(21), + chrome_mcp_rpc_timeout_sec: Some(61), ..Default::default() }; let json_val = serde_json::to_value(settings.to_json()).unwrap(); @@ -277,5 +349,14 @@ mod tests { assert_eq!(loaded.default_model, Some("gpt-5.2".into())); assert_eq!(loaded.default_reasoning_effort, Some("high".into())); assert_eq!(loaded.default_model_openai, Some("gpt-5.2".into())); + assert_eq!(loaded.chrome_mcp_enabled, Some(true)); + assert_eq!(loaded.chrome_mcp_auto_connect, Some(false)); + assert_eq!( + loaded.chrome_mcp_browser_url, + Some("http://127.0.0.1:9222".into()) + ); + assert_eq!(loaded.chrome_mcp_channel, Some("beta".into())); + assert_eq!(loaded.chrome_mcp_connect_timeout_sec, Some(21)); + assert_eq!(loaded.chrome_mcp_rpc_timeout_sec, Some(61)); } } diff --git a/openplanter-desktop/crates/op-core/src/tools/chrome_mcp.rs b/openplanter-desktop/crates/op-core/src/tools/chrome_mcp.rs new file mode 100644 index 00000000..b4e7d14a --- /dev/null +++ b/openplanter-desktop/crates/op-core/src/tools/chrome_mcp.rs @@ -0,0 +1,596 @@ +use std::env; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, anyhow}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use crate::config::{ + AgentConfig, normalize_chrome_mcp_browser_url, normalize_chrome_mcp_channel, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChromeMcpConfigKey { + pub enabled: bool, + pub auto_connect: bool, + pub browser_url: Option, + pub channel: String, + pub connect_timeout_sec: i64, + pub rpc_timeout_sec: i64, +} + +impl ChromeMcpConfigKey { + pub fn from_config(config: &AgentConfig) -> Self { + Self { + enabled: config.chrome_mcp_enabled, + auto_connect: config.chrome_mcp_auto_connect, + browser_url: normalize_chrome_mcp_browser_url(config.chrome_mcp_browser_url.as_deref()), + channel: normalize_chrome_mcp_channel(Some(&config.chrome_mcp_channel)), + connect_timeout_sec: config.chrome_mcp_connect_timeout_sec.max(1), + rpc_timeout_sec: config.chrome_mcp_rpc_timeout_sec.max(1), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChromeMcpToolDef { + pub name: String, + pub description: String, + pub parameters: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChromeMcpStatus { + pub status: String, + pub detail: String, + pub tool_count: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh_ms: Option, +} + +impl ChromeMcpStatus { + fn disabled() -> Self { + Self { + status: "disabled".into(), + detail: "Chrome DevTools MCP is disabled.".into(), + tool_count: 0, + last_refresh_ms: None, + } + } + + fn pending() -> Self { + Self { + status: "ready".into(), + detail: "Chrome DevTools MCP will initialize on the next solve.".into(), + tool_count: 0, + last_refresh_ms: None, + } + } +} + +struct ChromeMcpInner { + child: Option, + stdin: Option, + stdout: Option>>, + stderr_task: Option>, + stderr_tail: Arc>>, + next_request_id: u64, + tools: Vec, + last_refresh_ms: Option, + status: ChromeMcpStatus, +} + +impl ChromeMcpInner { + fn new(enabled: bool) -> Self { + Self { + child: None, + stdin: None, + stdout: None, + stderr_task: None, + stderr_tail: Arc::new(Mutex::new(Vec::new())), + next_request_id: 1, + tools: Vec::new(), + last_refresh_ms: None, + status: if enabled { + ChromeMcpStatus::pending() + } else { + ChromeMcpStatus::disabled() + }, + } + } +} + +pub struct ChromeMcpManager { + config: ChromeMcpConfigKey, + inner: Mutex, +} + +impl ChromeMcpManager { + pub fn new(config: ChromeMcpConfigKey) -> Self { + let enabled = config.enabled; + Self { + config, + inner: Mutex::new(ChromeMcpInner::new(enabled)), + } + } + + pub async fn status_snapshot(&self) -> ChromeMcpStatus { + self.inner.lock().await.status.clone() + } + + pub async fn list_tools(&self, force_refresh: bool) -> anyhow::Result> { + if !self.config.enabled { + return Ok(Vec::new()); + } + let mut last_error: Option = None; + for attempt in 0..2 { + let mut inner = self.inner.lock().await; + match self.list_tools_locked(&mut inner, force_refresh).await { + Ok(tools) => return Ok(tools), + Err(err) => { + last_error = Some(err); + self.shutdown_locked(&mut inner).await; + if attempt == 0 { + continue; + } + } + } + } + Err(last_error.unwrap_or_else(|| anyhow!("Chrome DevTools MCP tools/list failed"))) + } + + pub async fn call_tool(&self, name: &str, arguments: &Value) -> anyhow::Result { + if !self.config.enabled { + return Err(anyhow!("Chrome DevTools MCP is disabled.")); + } + let mut last_error: Option = None; + for attempt in 0..2 { + let mut inner = self.inner.lock().await; + match self.call_tool_locked(&mut inner, name, arguments).await { + Ok(result) => return Ok(result), + Err(err) => { + last_error = Some(err); + self.shutdown_locked(&mut inner).await; + if attempt == 0 { + continue; + } + } + } + } + Err(last_error.unwrap_or_else(|| anyhow!("Chrome DevTools MCP tools/call failed"))) + } + + pub async fn shutdown(&self) { + let mut inner = self.inner.lock().await; + self.shutdown_locked(&mut inner).await; + } + + async fn list_tools_locked( + &self, + inner: &mut ChromeMcpInner, + force_refresh: bool, + ) -> anyhow::Result> { + if !force_refresh && !inner.tools.is_empty() { + return Ok(inner.tools.clone()); + } + self.ensure_connected_locked(inner).await?; + let mut tools = Vec::new(); + let mut cursor: Option = None; + loop { + let mut params = serde_json::Map::new(); + if let Some(current) = cursor.as_deref() { + params.insert("cursor".into(), Value::String(current.to_string())); + } + let result = self + .request_locked( + inner, + "tools/list", + Value::Object(params), + self.config.rpc_timeout_sec, + ) + .await?; + if let Some(items) = result.get("tools").and_then(|value| value.as_array()) { + for item in items { + let Some(name) = item.get("name").and_then(|value| value.as_str()) else { + continue; + }; + let description = item + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let parameters = item + .get("inputSchema") + .cloned() + .unwrap_or_else(|| json!({"type":"object","properties":{},"required":[]})); + tools.push(ChromeMcpToolDef { + name: name.to_string(), + description, + parameters, + }); + } + } + cursor = result + .get("nextCursor") + .and_then(|value| value.as_str()) + .map(str::to_string); + if cursor.is_none() { + break; + } + } + let status = ChromeMcpStatus { + status: "ready".into(), + detail: format!( + "Chrome DevTools MCP ready with {} tool(s) via {}.", + tools.len(), + if self.config.browser_url.is_some() { + "browser_url" + } else { + "auto-connect" + } + ), + tool_count: tools.len(), + last_refresh_ms: Some(Utc::now().timestamp_millis()), + }; + inner.last_refresh_ms = status.last_refresh_ms; + inner.status = status; + inner.tools = tools.clone(); + Ok(tools) + } + + async fn call_tool_locked( + &self, + inner: &mut ChromeMcpInner, + name: &str, + arguments: &Value, + ) -> anyhow::Result { + self.ensure_connected_locked(inner).await?; + if inner.tools.is_empty() { + let _ = self.list_tools_locked(inner, false).await?; + } + let result = self + .request_locked( + inner, + "tools/call", + json!({ + "name": name, + "arguments": arguments, + }), + self.config.rpc_timeout_sec, + ) + .await?; + Ok(parse_call_result(&result)) + } + + async fn ensure_connected_locked(&self, inner: &mut ChromeMcpInner) -> anyhow::Result<()> { + if !self.config.enabled { + inner.status = ChromeMcpStatus::disabled(); + return Ok(()); + } + if inner.child.is_some() && inner.stdin.is_some() && inner.stdout.is_some() { + return Ok(()); + } + if self.config.browser_url.is_none() && !self.config.auto_connect { + let detail = "Chrome DevTools MCP is enabled but cannot attach: set `chrome_mcp_browser_url` or enable `chrome_mcp_auto_connect`.".to_string(); + inner.status = ChromeMcpStatus { + status: "unavailable".into(), + detail: detail.clone(), + tool_count: inner.tools.len(), + last_refresh_ms: inner.last_refresh_ms, + }; + return Err(anyhow!(detail)); + } + self.spawn_locked(inner).await?; + if let Err(err) = self + .request_locked( + inner, + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "openplanter-desktop", "version": "1.0" } + }), + self.config.connect_timeout_sec, + ) + .await + { + let detail = self.status_detail_from_error(&err, inner).await; + inner.status = ChromeMcpStatus { + status: "unavailable".into(), + detail: detail.clone(), + tool_count: inner.tools.len(), + last_refresh_ms: inner.last_refresh_ms, + }; + return Err(anyhow!(detail)); + } + self.notify_locked(inner, "notifications/initialized", json!({})) + .await?; + inner.status = ChromeMcpStatus::pending(); + Ok(()) + } + + async fn request_locked( + &self, + inner: &mut ChromeMcpInner, + method: &str, + params: Value, + timeout_sec: i64, + ) -> anyhow::Result { + let request_id = inner.next_request_id; + inner.next_request_id += 1; + let payload = json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + }); + let stdin = inner + .stdin + .as_mut() + .ok_or_else(|| anyhow!("Chrome DevTools MCP stdin is unavailable"))?; + stdin + .write_all(format!("{}\n", payload).as_bytes()) + .await + .with_context(|| format!("failed to write Chrome DevTools MCP request {method}"))?; + stdin.flush().await?; + + let stdout = inner + .stdout + .as_mut() + .ok_or_else(|| anyhow!("Chrome DevTools MCP stdout is unavailable"))?; + let response = timeout( + Duration::from_secs(timeout_sec.max(1) as u64), + async { + loop { + let maybe_line = stdout.next_line().await?; + let line = maybe_line.ok_or_else(|| anyhow!("Chrome DevTools MCP closed stdout"))?; + let Ok(payload): Result = serde_json::from_str(&line) else { + continue; + }; + let Some(id) = payload.get("id").and_then(|value| value.as_u64()) else { + continue; + }; + if id == request_id { + return Ok::(payload); + } + } + }, + ) + .await + .map_err(|_| anyhow!("Timed out waiting for Chrome DevTools MCP {method} response."))??; + + if let Some(err) = response.get("error") { + return Err(anyhow!(format_protocol_error(err))); + } + + Ok(response.get("result").cloned().unwrap_or(Value::Null)) + } + + async fn notify_locked( + &self, + inner: &mut ChromeMcpInner, + method: &str, + params: Value, + ) -> anyhow::Result<()> { + let stdin = inner + .stdin + .as_mut() + .ok_or_else(|| anyhow!("Chrome DevTools MCP stdin is unavailable"))?; + let payload = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + }); + stdin + .write_all(format!("{}\n", payload).as_bytes()) + .await + .with_context(|| format!("failed to write Chrome DevTools MCP notification {method}"))?; + stdin.flush().await?; + Ok(()) + } + + async fn spawn_locked(&self, inner: &mut ChromeMcpInner) -> anyhow::Result<()> { + self.shutdown_locked(inner).await; + let command = env::var("OPENPLANTER_CHROME_MCP_COMMAND").unwrap_or_else(|_| "npx".into()); + let package = env::var("OPENPLANTER_CHROME_MCP_PACKAGE") + .unwrap_or_else(|_| "chrome-devtools-mcp@latest".into()); + let mut args = vec!["-y".to_string(), package]; + if let Some(browser_url) = self.config.browser_url.as_deref() { + args.push(format!("--browserUrl={browser_url}")); + } else { + args.push("--autoConnect".into()); + args.push(format!("--channel={}", self.config.channel)); + } + if let Ok(extra_args) = env::var("OPENPLANTER_CHROME_MCP_EXTRA_ARGS") { + args.extend(extra_args.split_whitespace().map(str::to_string)); + } + let mut child = Command::new(&command) + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| { + format!( + "failed to spawn Chrome DevTools MCP command `{}`. Install Node.js/npm so `npx` is available locally.", + command + ) + })?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("Chrome DevTools MCP stdin pipe is unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Chrome DevTools MCP stdout pipe is unavailable"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow!("Chrome DevTools MCP stderr pipe is unavailable"))?; + let stderr_tail = inner.stderr_tail.clone(); + inner.stderr_task = Some(tokio::spawn(async move { + let _ = read_stderr(stderr, stderr_tail).await; + })); + inner.stdin = Some(stdin); + inner.stdout = Some(BufReader::new(stdout).lines()); + inner.child = Some(child); + Ok(()) + } + + async fn shutdown_locked(&self, inner: &mut ChromeMcpInner) { + if let Some(task) = inner.stderr_task.take() { + task.abort(); + } + inner.stdin = None; + inner.stdout = None; + if let Some(mut child) = inner.child.take() { + let _ = child.kill().await; + let _ = child.wait().await; + } + } + + async fn status_detail_from_error( + &self, + error: &anyhow::Error, + inner: &ChromeMcpInner, + ) -> String { + let mut detail = error.to_string(); + let stderr_tail = inner.stderr_tail.lock().await.clone(); + let stderr_text = stderr_tail + .iter() + .rev() + .take(4) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" "); + let lower = format!("{detail} {stderr_text}").to_lowercase(); + if !stderr_text.trim().is_empty() { + detail = format!("{detail} stderr: {stderr_text}"); + } + if lower.contains("timed out") || lower.contains("timeout") { + if self.config.browser_url.is_some() { + detail.push_str(" Confirm the configured browser URL is reachable."); + } else { + detail.push_str( + " Enable Chrome remote debugging at chrome://inspect/#remote-debugging and allow the connection prompt in Chrome.", + ); + } + } + if lower.contains("no such file") || lower.contains("not found") || lower.contains("spawn") { + detail.push_str(" Install Node.js/npm so `npx` is available locally."); + } + if self.config.browser_url.is_none() && !lower.contains("inspect/#remote-debugging") { + detail.push_str( + " Chrome 144+ must have remote debugging enabled at chrome://inspect/#remote-debugging.", + ); + } + detail + } +} + +async fn read_stderr(stderr: ChildStderr, sink: Arc>>) -> anyhow::Result<()> { + let mut lines = BufReader::new(stderr).lines(); + while let Some(line) = lines.next_line().await? { + let mut sink = sink.lock().await; + sink.push(line); + if sink.len() > 20 { + let excess = sink.len() - 20; + sink.drain(0..excess); + } + } + Ok(()) +} + +fn format_protocol_error(error: &Value) -> String { + let message = error + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("Unknown MCP error"); + match error.get("code").and_then(|value| value.as_i64()) { + Some(code) => format!("{message} (code {code})"), + None => message.to_string(), + } +} + +fn parse_call_result(result: &Value) -> String { + let mut content_parts: Vec = Vec::new(); + if let Some(content) = result.get("content").and_then(|value| value.as_array()) { + for item in content { + if let Some(text) = item.as_str() { + if !text.trim().is_empty() { + content_parts.push(text.trim().to_string()); + } + continue; + } + let item_type = item + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_lowercase(); + match item_type.as_str() { + "text" => { + if let Some(text) = item.get("text").and_then(|value| value.as_str()) { + if !text.trim().is_empty() { + content_parts.push(text.trim().to_string()); + } + } + } + "image" => { + let media_type = item + .get("mimeType") + .or_else(|| item.get("mediaType")) + .and_then(|value| value.as_str()) + .unwrap_or("image"); + content_parts.push(format!("[{media_type} attached]")); + } + _ => { + if let Some(uri) = item + .get("uri") + .or_else(|| item.get("url")) + .and_then(|value| value.as_str()) + { + let label = item + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("resource"); + content_parts.push(format!("{label}: {uri}")); + } + } + } + } + } + if content_parts.is_empty() { + if let Some(structured) = result.get("structuredContent") { + content_parts.push( + serde_json::to_string_pretty(structured) + .unwrap_or_else(|_| structured.to_string()), + ); + } + } + let mut content = if content_parts.is_empty() { + "Chrome DevTools MCP tool completed with no textual output.".to_string() + } else { + content_parts.join("\n") + }; + if result + .get("isError") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + content = format!("Chrome DevTools MCP tool error: {content}"); + } + content +} diff --git a/openplanter-desktop/crates/op-core/src/tools/defs.rs b/openplanter-desktop/crates/op-core/src/tools/defs.rs index 0c422bc3..6456b7d9 100644 --- a/openplanter-desktop/crates/op-core/src/tools/defs.rs +++ b/openplanter-desktop/crates/op-core/src/tools/defs.rs @@ -5,6 +5,8 @@ use serde_json::{json, Value}; +use super::chrome_mcp::ChromeMcpToolDef; + struct ToolDef { name: &'static str, description: &'static str, @@ -356,6 +358,27 @@ fn mvp_tool_defs() -> Vec { ] } +fn merged_tool_defs(dynamic_defs: &[ChromeMcpToolDef]) -> Vec<(String, String, Value)> { + let mut defs: Vec<(String, String, Value)> = mvp_tool_defs() + .into_iter() + .map(|def| (def.name.to_string(), def.description.to_string(), def.parameters)) + .collect(); + let mut existing: std::collections::HashSet = + defs.iter().map(|(name, _, _)| name.clone()).collect(); + for def in dynamic_defs { + if existing.contains(&def.name) { + continue; + } + defs.push(( + def.name.clone(), + def.description.clone(), + def.parameters.clone(), + )); + existing.insert(def.name.clone()); + } + defs +} + /// For OpenAI strict mode: make all properties required, wrapping optional ones /// with `anyOf [original, null]`. Recurse into nested objects and array items. fn strict_fixup(schema: &mut Value) { @@ -429,16 +452,20 @@ fn strict_fixup(schema: &mut Value) { /// Convert to OpenAI tools format: `[{ type: "function", function: { name, description, parameters, strict } }]` pub fn to_openai_tools() -> Vec { - mvp_tool_defs() + to_openai_tools_with_dynamic(&[]) +} + +pub fn to_openai_tools_with_dynamic(dynamic_defs: &[ChromeMcpToolDef]) -> Vec { + merged_tool_defs(dynamic_defs) .into_iter() .map(|def| { - let mut params = def.parameters; + let (name, description, mut params) = def; strict_fixup(&mut params); json!({ "type": "function", "function": { - "name": def.name, - "description": def.description, + "name": name, + "description": description, "parameters": params, "strict": true } @@ -449,23 +476,28 @@ pub fn to_openai_tools() -> Vec { /// Convert to Anthropic tools format: `[{ name, description, input_schema }]` pub fn to_anthropic_tools() -> Vec { - mvp_tool_defs() + to_anthropic_tools_with_dynamic(&[]) +} + +pub fn to_anthropic_tools_with_dynamic(dynamic_defs: &[ChromeMcpToolDef]) -> Vec { + merged_tool_defs(dynamic_defs) .into_iter() .map(|def| { + let (name, description, parameters) = def; json!({ - "name": def.name, - "description": def.description, - "input_schema": def.parameters + "name": name, + "description": description, + "input_schema": parameters }) }) .collect() } /// Build tool definitions for the given provider. -pub fn build_tool_defs(provider: &str) -> Vec { +pub fn build_tool_defs(provider: &str, dynamic_defs: &[ChromeMcpToolDef]) -> Vec { match provider { - "anthropic" => to_anthropic_tools(), - _ => to_openai_tools(), + "anthropic" => to_anthropic_tools_with_dynamic(dynamic_defs), + _ => to_openai_tools_with_dynamic(dynamic_defs), } } @@ -572,14 +604,14 @@ mod tests { #[test] fn test_build_tool_defs_anthropic() { - let tools = build_tool_defs("anthropic"); + let tools = build_tool_defs("anthropic", &[]); assert!(tools[0].get("input_schema").is_some()); assert!(tools[0].get("type").is_none()); } #[test] fn test_build_tool_defs_openai() { - let tools = build_tool_defs("openai"); + let tools = build_tool_defs("openai", &[]); assert_eq!(tools[0]["type"], "function"); } diff --git a/openplanter-desktop/crates/op-core/src/tools/mod.rs b/openplanter-desktop/crates/op-core/src/tools/mod.rs index ce85b3b8..a2301ad4 100644 --- a/openplanter-desktop/crates/op-core/src/tools/mod.rs +++ b/openplanter-desktop/crates/op-core/src/tools/mod.rs @@ -3,14 +3,16 @@ /// The `WorkspaceTools` struct is the central dispatcher that owns tool state /// (files-read set, background jobs) and routes tool calls to the appropriate module. pub mod audio; +pub mod chrome_mcp; pub mod defs; pub mod filesystem; +pub mod patching; pub mod shell; pub mod web; -pub mod patching; use std::collections::HashSet; use std::path::PathBuf; +use std::sync::Arc; use crate::config::AgentConfig; @@ -64,12 +66,25 @@ pub struct WorkspaceTools { mistral_transcription_chunk_overlap_seconds: f64, mistral_transcription_max_chunks: i64, mistral_transcription_request_timeout_sec: u64, + chrome_mcp: Option>, files_read: HashSet, bg_jobs: shell::BgJobs, } +fn clip(text: &str, max_chars: usize) -> String { + if text.len() <= max_chars { + return text.to_string(); + } + let end = text.floor_char_boundary(max_chars); + let omitted = text.len() - end; + format!("{}\n\n...[truncated {omitted} chars]...", &text[..end]) +} + impl WorkspaceTools { - pub fn new(config: &AgentConfig) -> Self { + pub fn new( + config: &AgentConfig, + chrome_mcp: Option>, + ) -> Self { Self { root: config.workspace.clone(), scope: ToolScope::FullWorkspace, @@ -93,6 +108,7 @@ impl WorkspaceTools { mistral_transcription_request_timeout_sec: config .mistral_transcription_request_timeout_sec as u64, + chrome_mcp, files_read: HashSet::new(), bg_jobs: shell::BgJobs::new(), } @@ -127,6 +143,7 @@ impl WorkspaceTools { mistral_transcription_request_timeout_sec: config .mistral_transcription_request_timeout_sec as u64, + chrome_mcp: None, files_read: HashSet::new(), bg_jobs: shell::BgJobs::new(), } @@ -397,17 +414,32 @@ impl WorkspaceTools { ToolResult::ok(format!("Noted: {note}")) } - _ => ToolResult::error(format!("Unknown tool: {name}")), + _ => { + if let Some(manager) = &self.chrome_mcp { + match manager.list_tools(false).await { + Ok(tools) if tools.iter().any(|tool| tool.name == name) => { + match manager.call_tool(name, &args).await { + Ok(content) => ToolResult::ok(content), + Err(err) => { + ToolResult::error(format!("Chrome DevTools MCP unavailable: {err}")) + } + } + } + Ok(_) => ToolResult::error(format!("Unknown tool: {name}")), + Err(err) => { + ToolResult::error(format!("Chrome DevTools MCP unavailable: {err}")) + } + } + } else { + ToolResult::error(format!("Unknown tool: {name}")) + } + } }; // Clip observation to max_observation_chars if result.content.len() > self.max_observation_chars { - let omitted = result.content.len() - self.max_observation_chars; ToolResult { - content: format!( - "{}\n\n...[truncated {omitted} chars]...", - &result.content[..self.max_observation_chars] - ), + content: clip(&result.content, self.max_observation_chars), is_error: result.is_error, } } else { diff --git a/openplanter-desktop/crates/op-tauri/src/bridge.rs b/openplanter-desktop/crates/op-tauri/src/bridge.rs index ec7294f2..3b0e696e 100644 --- a/openplanter-desktop/crates/op-tauri/src/bridge.rs +++ b/openplanter-desktop/crates/op-tauri/src/bridge.rs @@ -198,17 +198,69 @@ struct PendingToolCall { /// Key argument names for tool call display (mirrors frontend KEY_ARGS). fn extract_key_arg(tool_name: &str, args_json: &str) -> Option { let key_name = match tool_name { - "read_file" | "write_file" | "edit_file" | "apply_patch" | "hashline_edit" => "path", - "list_files" => "directory", - "run_shell" | "run_shell_bg" => "command", - "kill_shell_bg" => "pid", - "web_search" => "query", - "fetch_url" => "url", - _ => return None, + "read_file" | "write_file" | "edit_file" | "apply_patch" | "hashline_edit" => Some("path"), + "list_files" => Some("directory"), + "run_shell" | "run_shell_bg" => Some("command"), + "kill_shell_bg" => Some("pid"), + "web_search" => Some("query"), + "fetch_url" => Some("url"), + _ => None, }; - let pattern = format!("\"{}\"\\s*:\\s*\"([^\"]*)\"?", regex::escape(key_name)); - let re = regex::Regex::new(&pattern).ok()?; - re.captures(args_json).map(|c| c[1].to_string()) + if let Ok(value) = serde_json::from_str::(args_json) { + if let Some(key) = key_name { + if let Some(found) = value + .get(key) + .and_then(preview_value) + .filter(|value| !value.is_empty()) + { + return Some(found); + } + } + return first_informative_value(&value); + } + if let Some(key) = key_name { + let pattern = format!("\"{}\"\\s*:\\s*\"([^\"]*)\"?", regex::escape(key)); + let re = regex::Regex::new(&pattern).ok()?; + if let Some(captures) = re.captures(args_json) { + return captures.get(1).map(|capture| capture.as_str().to_string()); + } + } + let re = regex::Regex::new(r#""[^"]+"\s*:\s*"([^"]+)""#).ok()?; + re.captures(args_json) + .and_then(|captures| captures.get(1)) + .map(|capture| capture.as_str().to_string()) +} + +fn preview_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.chars().take(60).collect()) + } + } + serde_json::Value::Array(items) => { + let collected = items + .iter() + .filter_map(|item| item.as_str().map(str::trim).filter(|text| !text.is_empty())) + .take(3) + .collect::>(); + if collected.is_empty() { + None + } else { + Some(collected.join(", ")) + } + } + serde_json::Value::Number(number) => Some(number.to_string()), + _ => None, + } +} + +fn first_informative_value(value: &serde_json::Value) -> Option { + let object = value.as_object()?; + object.values().find_map(preview_value) } impl LoggingEmitter { diff --git a/openplanter-desktop/crates/op-tauri/src/commands/agent.rs b/openplanter-desktop/crates/op-tauri/src/commands/agent.rs index d3eeb81f..36a533bd 100644 --- a/openplanter-desktop/crates/op-tauri/src/commands/agent.rs +++ b/openplanter-desktop/crates/op-tauri/src/commands/agent.rs @@ -50,6 +50,7 @@ pub async fn solve( state: State<'_, AppState>, ) -> Result<(), String> { let cfg = state.config.lock().await.clone(); + let chrome_mcp = state.chrome_mcp_manager(&cfg).await; let init_status = workspace_init::get_init_status(&cfg.workspace, &cfg.session_root_dir) .map_err(|e| e.to_string())?; if init_status.gate_state != "ready" { @@ -123,12 +124,13 @@ pub async fn solve( tokio::spawn(async move { let result = tokio::spawn(async move { - op_core::engine::solve_with_initial_context( + op_core::engine::solve_with_initial_context_and_chrome_mcp( &objective, &cfg, &emitter, token, Some(initial_context), + chrome_mcp, ) .await; }) diff --git a/openplanter-desktop/crates/op-tauri/src/commands/config.rs b/openplanter-desktop/crates/op-tauri/src/commands/config.rs index e88fb985..db9ca1cc 100644 --- a/openplanter-desktop/crates/op-tauri/src/commands/config.rs +++ b/openplanter-desktop/crates/op-tauri/src/commands/config.rs @@ -1,28 +1,78 @@ use std::collections::HashMap; -use tauri::State; use crate::state::AppState; +use op_core::config::{normalize_chrome_mcp_browser_url, normalize_chrome_mcp_channel}; +use op_core::credentials::credentials_from_env; use op_core::events::{ConfigView, ModelInfo, PartialConfig}; use op_core::settings::{PersistentSettings, SettingsStore}; -use op_core::credentials::credentials_from_env; +use tauri::State; -/// Get the current configuration. -#[tauri::command] -pub async fn get_config( - state: State<'_, AppState>, -) -> Result { - let cfg = state.config.lock().await; - let session_id = state.session_id.lock().await; - Ok(ConfigView { +async fn make_config_view( + cfg: &op_core::config::AgentConfig, + session_id: Option, + state: &AppState, +) -> ConfigView { + let chrome_status = state.chrome_mcp_status(cfg).await; + ConfigView { provider: cfg.provider.clone(), model: cfg.model.clone(), reasoning_effort: cfg.reasoning_effort.clone(), + chrome_mcp_enabled: cfg.chrome_mcp_enabled, + chrome_mcp_auto_connect: cfg.chrome_mcp_auto_connect, + chrome_mcp_browser_url: cfg.chrome_mcp_browser_url.clone(), + chrome_mcp_channel: cfg.chrome_mcp_channel.clone(), + chrome_mcp_connect_timeout_sec: cfg.chrome_mcp_connect_timeout_sec, + chrome_mcp_rpc_timeout_sec: cfg.chrome_mcp_rpc_timeout_sec, + chrome_mcp_status: chrome_status.status, + chrome_mcp_status_detail: chrome_status.detail, workspace: cfg.workspace.display().to_string(), - session_id: session_id.clone(), + session_id, recursive: cfg.recursive, max_depth: cfg.max_depth, max_steps_per_call: cfg.max_steps_per_call, demo: cfg.demo, - }) + } +} + +fn merge_settings(existing: PersistentSettings, incoming: PersistentSettings) -> PersistentSettings { + PersistentSettings { + default_model: incoming.default_model.or(existing.default_model), + default_reasoning_effort: incoming + .default_reasoning_effort + .or(existing.default_reasoning_effort), + default_model_openai: incoming.default_model_openai.or(existing.default_model_openai), + default_model_anthropic: incoming + .default_model_anthropic + .or(existing.default_model_anthropic), + default_model_openrouter: incoming + .default_model_openrouter + .or(existing.default_model_openrouter), + default_model_cerebras: incoming + .default_model_cerebras + .or(existing.default_model_cerebras), + default_model_ollama: incoming.default_model_ollama.or(existing.default_model_ollama), + chrome_mcp_enabled: incoming.chrome_mcp_enabled.or(existing.chrome_mcp_enabled), + chrome_mcp_auto_connect: incoming + .chrome_mcp_auto_connect + .or(existing.chrome_mcp_auto_connect), + chrome_mcp_browser_url: incoming + .chrome_mcp_browser_url + .or(existing.chrome_mcp_browser_url), + chrome_mcp_channel: incoming.chrome_mcp_channel.or(existing.chrome_mcp_channel), + chrome_mcp_connect_timeout_sec: incoming + .chrome_mcp_connect_timeout_sec + .or(existing.chrome_mcp_connect_timeout_sec), + chrome_mcp_rpc_timeout_sec: incoming + .chrome_mcp_rpc_timeout_sec + .or(existing.chrome_mcp_rpc_timeout_sec), + } +} + +/// Get the current configuration. +#[tauri::command] +pub async fn get_config(state: State<'_, AppState>) -> Result { + let cfg = state.config.lock().await.clone(); + let session_id = state.session_id.lock().await.clone(); + Ok(make_config_view(&cfg, session_id, &state).await) } /// Update configuration fields. @@ -45,18 +95,29 @@ pub async fn update_config( Some(effort) }; } - let session_id = state.session_id.lock().await; - Ok(ConfigView { - provider: cfg.provider.clone(), - model: cfg.model.clone(), - reasoning_effort: cfg.reasoning_effort.clone(), - workspace: cfg.workspace.display().to_string(), - session_id: session_id.clone(), - recursive: cfg.recursive, - max_depth: cfg.max_depth, - max_steps_per_call: cfg.max_steps_per_call, - demo: cfg.demo, - }) + if let Some(enabled) = partial.chrome_mcp_enabled { + cfg.chrome_mcp_enabled = enabled; + } + if let Some(auto_connect) = partial.chrome_mcp_auto_connect { + cfg.chrome_mcp_auto_connect = auto_connect; + } + if let Some(browser_url) = partial.chrome_mcp_browser_url { + cfg.chrome_mcp_browser_url = normalize_chrome_mcp_browser_url(Some(&browser_url)); + } + if let Some(channel) = partial.chrome_mcp_channel { + cfg.chrome_mcp_channel = normalize_chrome_mcp_channel(Some(&channel)); + } + if let Some(timeout) = partial.chrome_mcp_connect_timeout_sec { + cfg.chrome_mcp_connect_timeout_sec = timeout.max(1); + } + if let Some(timeout) = partial.chrome_mcp_rpc_timeout_sec { + cfg.chrome_mcp_rpc_timeout_sec = timeout.max(1); + } + let cfg_snapshot = cfg.clone(); + drop(cfg); + state.sync_chrome_mcp_config(&cfg_snapshot).await; + let session_id = state.session_id.lock().await.clone(); + Ok(make_config_view(&cfg_snapshot, session_id, &state).await) } /// Known models per provider for listing. @@ -130,7 +191,8 @@ pub async fn save_settings( ) -> Result<(), String> { let cfg = state.config.lock().await; let store = SettingsStore::new(&cfg.workspace, &cfg.session_root_dir); - store.save(&settings).map_err(|e| e.to_string()) + let merged = merge_settings(store.load(), settings); + store.save(&merged).map_err(|e| e.to_string()) } /// Build credential status from config: which providers/services have API keys configured. diff --git a/openplanter-desktop/crates/op-tauri/src/state.rs b/openplanter-desktop/crates/op-tauri/src/state.rs index 0c878a8e..14b0d21c 100644 --- a/openplanter-desktop/crates/op-tauri/src/state.rs +++ b/openplanter-desktop/crates/op-tauri/src/state.rs @@ -5,6 +5,7 @@ use op_core::credentials::{ credentials_from_env, discover_env_candidates, parse_env_assignments, parse_env_file, }; use op_core::settings::SettingsStore; +use op_core::tools::chrome_mcp::{ChromeMcpConfigKey, ChromeMcpManager, ChromeMcpStatus}; use op_core::workspace_init; use std::env; use std::fmt; @@ -390,9 +391,16 @@ pub struct AppState { pub cancel_token: Arc>, pub agent_running: Arc>, pub init_lock: Arc>, + pub chrome_mcp: Arc>, startup_trace: String, } +#[derive(Default)] +pub struct ChromeMcpRuntime { + key: Option, + manager: Option>, +} + impl AppState { pub fn try_new() -> Result { let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); @@ -428,6 +436,7 @@ impl AppState { cancel_token: Arc::new(Mutex::new(CancellationToken::new())), agent_running: Arc::new(Mutex::new(false)), init_lock: Arc::new(Mutex::new(())), + chrome_mcp: Arc::new(Mutex::new(ChromeMcpRuntime::default())), startup_trace: format_startup_trace(¤t_dir, &resolved_workspace, &migration), }) } @@ -435,6 +444,82 @@ impl AppState { pub fn startup_trace(&self) -> &str { &self.startup_trace } + + pub async fn sync_chrome_mcp_config(&self, cfg: &AgentConfig) { + let key = ChromeMcpConfigKey::from_config(cfg); + let mut runtime = self.chrome_mcp.lock().await; + if runtime.key.as_ref() == Some(&key) { + return; + } + if let Some(manager) = runtime.manager.take() { + tokio::spawn(async move { + manager.shutdown().await; + }); + } + runtime.key = Some(key); + } + + pub async fn chrome_mcp_manager(&self, cfg: &AgentConfig) -> Option> { + let key = ChromeMcpConfigKey::from_config(cfg); + let mut runtime = self.chrome_mcp.lock().await; + if !key.enabled { + if let Some(manager) = runtime.manager.take() { + tokio::spawn(async move { + manager.shutdown().await; + }); + } + runtime.key = Some(key); + return None; + } + if runtime.key.as_ref() != Some(&key) { + if let Some(manager) = runtime.manager.take() { + tokio::spawn(async move { + manager.shutdown().await; + }); + } + runtime.key = Some(key.clone()); + } + if runtime.manager.is_none() { + runtime.manager = Some(Arc::new(ChromeMcpManager::new(key))); + } + runtime.manager.clone() + } + + pub async fn chrome_mcp_status(&self, cfg: &AgentConfig) -> ChromeMcpStatus { + let key = ChromeMcpConfigKey::from_config(cfg); + let manager = { + let runtime = self.chrome_mcp.lock().await; + if runtime.key.as_ref() == Some(&key) { + runtime.manager.clone() + } else { + None + } + }; + if let Some(manager) = manager { + manager.status_snapshot().await + } else if !key.enabled { + ChromeMcpStatus { + status: "disabled".into(), + detail: "Chrome DevTools MCP is disabled.".into(), + tool_count: 0, + last_refresh_ms: None, + } + } else if key.browser_url.is_none() && !key.auto_connect { + ChromeMcpStatus { + status: "unavailable".into(), + detail: "Chrome DevTools MCP is enabled but cannot attach: set `chrome_mcp_browser_url` or enable `chrome_mcp_auto_connect`.".into(), + tool_count: 0, + last_refresh_ms: None, + } + } else { + ChromeMcpStatus { + status: "ready".into(), + detail: "Chrome DevTools MCP will initialize on the next solve.".into(), + tool_count: 0, + last_refresh_ms: None, + } + } + } } #[cfg(test)] diff --git a/openplanter-desktop/frontend/src/api/types.ts b/openplanter-desktop/frontend/src/api/types.ts index f654d500..961ae193 100644 --- a/openplanter-desktop/frontend/src/api/types.ts +++ b/openplanter-desktop/frontend/src/api/types.ts @@ -108,6 +108,14 @@ export interface ConfigView { provider: string; model: string; reasoning_effort: string | null; + chrome_mcp_enabled: boolean; + chrome_mcp_auto_connect: boolean; + chrome_mcp_browser_url: string | null; + chrome_mcp_channel: string; + chrome_mcp_connect_timeout_sec: number; + chrome_mcp_rpc_timeout_sec: number; + chrome_mcp_status: string; + chrome_mcp_status_detail: string; workspace: string; session_id: string | null; recursive: boolean; @@ -120,6 +128,12 @@ export interface PartialConfig { provider?: string; model?: string; reasoning_effort?: string; + chrome_mcp_enabled?: boolean; + chrome_mcp_auto_connect?: boolean; + chrome_mcp_browser_url?: string | null; + chrome_mcp_channel?: string; + chrome_mcp_connect_timeout_sec?: number; + chrome_mcp_rpc_timeout_sec?: number; } export interface ModelInfo { @@ -143,6 +157,12 @@ export interface PersistentSettings { default_model_openrouter?: string | null; default_model_cerebras?: string | null; default_model_ollama?: string | null; + chrome_mcp_enabled?: boolean | null; + chrome_mcp_auto_connect?: boolean | null; + chrome_mcp_browser_url?: string | null; + chrome_mcp_channel?: string | null; + chrome_mcp_connect_timeout_sec?: number | null; + chrome_mcp_rpc_timeout_sec?: number | null; } export interface SlashResult { diff --git a/openplanter-desktop/frontend/src/commands/chrome.test.ts b/openplanter-desktop/frontend/src/commands/chrome.test.ts new file mode 100644 index 00000000..3dba7121 --- /dev/null +++ b/openplanter-desktop/frontend/src/commands/chrome.test.ts @@ -0,0 +1,126 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { __setHandler, __clearHandlers } from "../__mocks__/tauri"; + +vi.mock("@tauri-apps/api/core", async () => { + const mock = await import("../__mocks__/tauri"); + return { invoke: mock.invoke }; +}); + +import { appState } from "../state/store"; +import { CHROME_USAGE, handleChromeCommand } from "./chrome"; + +function makeChromeConfig(overrides: Record = {}) { + return { + provider: "anthropic", + model: "claude-opus-4-6", + reasoning_effort: "medium", + chrome_mcp_enabled: true, + chrome_mcp_auto_connect: true, + chrome_mcp_browser_url: null, + chrome_mcp_channel: "stable", + chrome_mcp_connect_timeout_sec: 15, + chrome_mcp_rpc_timeout_sec: 45, + chrome_mcp_status: "ready", + chrome_mcp_status_detail: "Connected to Chrome.", + workspace: ".", + session_id: null, + recursive: true, + max_depth: 4, + max_steps_per_call: 100, + demo: false, + ...overrides, + }; +} + +describe("handleChromeCommand", () => { + const originalState = appState.get(); + + beforeEach(() => { + appState.set({ + ...originalState, + chromeMcpEnabled: false, + chromeMcpAutoConnect: true, + chromeMcpBrowserUrl: null, + chromeMcpChannel: "stable", + chromeMcpStatus: "disabled", + chromeMcpStatusDetail: "Chrome DevTools MCP is disabled.", + }); + }); + + afterEach(() => { + __clearHandlers(); + appState.set(originalState); + }); + + it("shows current status with usage when called without args", async () => { + const result = await handleChromeCommand(""); + expect(result.lines[0]).toContain("Chrome MCP:"); + expect(result.lines[1]).toContain("Chrome runtime:"); + expect(result.lines).toContain(CHROME_USAGE); + }); + + it("updates auto-connect mode", async () => { + __setHandler("update_config", ({ partial }: { partial: Record }) => { + expect(partial.chrome_mcp_enabled).toBe(true); + expect(partial.chrome_mcp_auto_connect).toBe(true); + expect(partial.chrome_mcp_browser_url).toBe(""); + return makeChromeConfig(); + }); + + const result = await handleChromeCommand("auto"); + expect(result.lines[0]).toContain("attach=auto-connect"); + expect(appState.get().chromeMcpEnabled).toBe(true); + expect(appState.get().chromeMcpAutoConnect).toBe(true); + expect(appState.get().chromeMcpBrowserUrl).toBeNull(); + }); + + it("updates explicit browser url and persists when requested", async () => { + __setHandler("update_config", ({ partial }: { partial: Record }) => { + expect(partial.chrome_mcp_enabled).toBe(true); + expect(partial.chrome_mcp_auto_connect).toBe(false); + expect(partial.chrome_mcp_browser_url).toBe("http://127.0.0.1:9222"); + return makeChromeConfig({ + chrome_mcp_auto_connect: false, + chrome_mcp_browser_url: "http://127.0.0.1:9222", + chrome_mcp_status_detail: "Attached to remote debugging endpoint.", + }); + }); + __setHandler("save_settings", ({ settings }: { settings: Record }) => { + expect(settings.chrome_mcp_enabled).toBe(true); + expect(settings.chrome_mcp_auto_connect).toBe(false); + expect(settings.chrome_mcp_browser_url).toBe("http://127.0.0.1:9222"); + expect(settings.chrome_mcp_channel).toBe("stable"); + }); + + const result = await handleChromeCommand("url http://127.0.0.1:9222 --save"); + expect(result.lines[0]).toContain("browser_url=http://127.0.0.1:9222"); + expect(result.lines).toContain("(Settings saved)"); + expect(appState.get().chromeMcpBrowserUrl).toBe("http://127.0.0.1:9222"); + }); + + it("updates the Chrome channel", async () => { + __setHandler("update_config", ({ partial }: { partial: Record }) => { + expect(partial.chrome_mcp_channel).toBe("beta"); + return makeChromeConfig({ + chrome_mcp_channel: "beta", + chrome_mcp_status: "unavailable", + chrome_mcp_status_detail: "Chrome Beta is not running.", + }); + }); + + const result = await handleChromeCommand("channel beta"); + expect(result.lines[0]).toContain("channel=beta"); + expect(result.lines[1]).toContain("unavailable"); + expect(appState.get().chromeMcpChannel).toBe("beta"); + }); + + it("rejects invalid channels", async () => { + const result = await handleChromeCommand("channel nightly"); + expect(result.lines[0]).toContain("Invalid Chrome channel"); + }); + + it("shows url usage when endpoint is missing", async () => { + const result = await handleChromeCommand("url"); + expect(result.lines).toEqual(["Usage: /chrome url [--save]"]); + }); +}); diff --git a/openplanter-desktop/frontend/src/commands/chrome.ts b/openplanter-desktop/frontend/src/commands/chrome.ts new file mode 100644 index 00000000..83b76ff3 --- /dev/null +++ b/openplanter-desktop/frontend/src/commands/chrome.ts @@ -0,0 +1,138 @@ +/** /chrome slash command handler. */ +import { saveSettings, updateConfig } from "../api/invoke"; +import type { ConfigView } from "../api/types"; +import { appState, type AppState } from "../state/store"; +import type { CommandResult } from "./model"; + +export const VALID_CHROME_CHANNELS = ["stable", "beta", "dev", "canary"] as const; +export const CHROME_USAGE = + "Usage: /chrome status|on|off|auto|url |channel [--save]"; + +type ChromeStatusSource = Pick< + AppState, + | "chromeMcpEnabled" + | "chromeMcpAutoConnect" + | "chromeMcpBrowserUrl" + | "chromeMcpChannel" + | "chromeMcpStatus" + | "chromeMcpStatusDetail" +>; + +function applyChromeConfig(config: ConfigView): void { + appState.update((state) => ({ + ...state, + chromeMcpEnabled: config.chrome_mcp_enabled, + chromeMcpAutoConnect: config.chrome_mcp_auto_connect, + chromeMcpBrowserUrl: config.chrome_mcp_browser_url, + chromeMcpChannel: config.chrome_mcp_channel, + chromeMcpConnectTimeoutSec: config.chrome_mcp_connect_timeout_sec, + chromeMcpRpcTimeoutSec: config.chrome_mcp_rpc_timeout_sec, + chromeMcpStatus: config.chrome_mcp_status, + chromeMcpStatusDetail: config.chrome_mcp_status_detail, + })); +} + +function describeAttachMode(state: ChromeStatusSource): string { + if (state.chromeMcpBrowserUrl) { + return `browser_url=${state.chromeMcpBrowserUrl}`; + } + return state.chromeMcpAutoConnect ? "auto-connect" : "manual-disabled"; +} + +export function formatChromeStatusLines(state: ChromeStatusSource): string[] { + return [ + `Chrome MCP: enabled=${state.chromeMcpEnabled} | attach=${describeAttachMode(state)} | channel=${state.chromeMcpChannel}`, + `Chrome runtime: ${state.chromeMcpStatus} | ${state.chromeMcpStatusDetail}`, + ]; +} + +/** Handle /chrome [status|on|off|auto|url|channel]. */ +export async function handleChromeCommand(args: string): Promise { + const parts = args.trim().split(/\s+/).filter(Boolean); + const save = parts.includes("--save"); + const filtered = parts.filter((part) => part !== "--save"); + const action = filtered[0]?.toLowerCase() ?? ""; + + if (!action || action === "status") { + const lines = formatChromeStatusLines(appState.get()); + if (!action) { + lines.push(CHROME_USAGE); + } + return { action: "handled", lines }; + } + + let partial: Record; + switch (action) { + case "on": + partial = { chrome_mcp_enabled: true }; + break; + case "off": + partial = { chrome_mcp_enabled: false }; + break; + case "auto": + partial = { + chrome_mcp_enabled: true, + chrome_mcp_auto_connect: true, + // Tauri partial config treats `null` as "field omitted", so send an + // empty string and let the Rust normalizer clear the stored URL. + chrome_mcp_browser_url: "", + }; + break; + case "url": + if (filtered.length < 2) { + return { action: "handled", lines: ["Usage: /chrome url [--save]"] }; + } + partial = { + chrome_mcp_enabled: true, + chrome_mcp_auto_connect: false, + chrome_mcp_browser_url: filtered[1].trim(), + }; + break; + case "channel": { + const channel = filtered[1]?.trim().toLowerCase() ?? ""; + if (!channel) { + return { + action: "handled", + lines: ["Usage: /chrome channel [--save]"], + }; + } + if (!VALID_CHROME_CHANNELS.includes(channel as (typeof VALID_CHROME_CHANNELS)[number])) { + return { + action: "handled", + lines: [`Invalid Chrome channel "${channel}". Expected: ${VALID_CHROME_CHANNELS.join(", ")}`], + }; + } + partial = { chrome_mcp_channel: channel }; + break; + } + default: + return { + action: "handled", + lines: [`Unknown /chrome action "${action}".`, CHROME_USAGE], + }; + } + + try { + const config = await updateConfig(partial); + applyChromeConfig(config); + + const lines = formatChromeStatusLines(appState.get()); + if (save) { + await saveSettings({ + chrome_mcp_enabled: config.chrome_mcp_enabled, + chrome_mcp_auto_connect: config.chrome_mcp_auto_connect, + chrome_mcp_browser_url: config.chrome_mcp_browser_url, + chrome_mcp_channel: config.chrome_mcp_channel, + chrome_mcp_connect_timeout_sec: config.chrome_mcp_connect_timeout_sec, + chrome_mcp_rpc_timeout_sec: config.chrome_mcp_rpc_timeout_sec, + }); + lines.push("(Settings saved)"); + } + return { action: "handled", lines }; + } catch (e) { + return { + action: "handled", + lines: [`Failed to update Chrome MCP settings: ${e}`], + }; + } +} diff --git a/openplanter-desktop/frontend/src/commands/completionRegistry.test.ts b/openplanter-desktop/frontend/src/commands/completionRegistry.test.ts index 8a24c5a2..3142fbe7 100644 --- a/openplanter-desktop/frontend/src/commands/completionRegistry.test.ts +++ b/openplanter-desktop/frontend/src/commands/completionRegistry.test.ts @@ -24,6 +24,7 @@ describe("completionRegistry", () => { expect(values).toContain("/status"); expect(values).toContain("/model"); expect(values).toContain("/reasoning"); + expect(values).toContain("/chrome"); expect(values).toContain("/init"); }); @@ -87,6 +88,31 @@ describe("completionRegistry", () => { } }); + it("/chrome has expected subcommands", () => { + const chromeCmd = COMMAND_COMPLETIONS.find((c) => c.value === "/chrome"); + expect(chromeCmd).toBeDefined(); + expect(chromeCmd!.children?.map((child) => child.value)).toEqual([ + "status", + "on", + "off", + "auto", + "url", + "channel", + ]); + }); + + it("/chrome channel exposes supported channels and save flag", () => { + const chromeCmd = COMMAND_COMPLETIONS.find((c) => c.value === "/chrome")!; + const channelCmd = chromeCmd.children!.find((c) => c.value === "channel")!; + expect(channelCmd.children?.map((child) => child.value)).toEqual([ + "stable", + "beta", + "dev", + "canary", + ]); + expect(channelCmd.children?.[0].children?.[0].value).toBe("--save"); + }); + it("/help has no children", () => { const helpCmd = COMMAND_COMPLETIONS.find((c) => c.value === "/help"); expect(helpCmd).toBeDefined(); diff --git a/openplanter-desktop/frontend/src/commands/completionRegistry.ts b/openplanter-desktop/frontend/src/commands/completionRegistry.ts index 1226104b..44459188 100644 --- a/openplanter-desktop/frontend/src/commands/completionRegistry.ts +++ b/openplanter-desktop/frontend/src/commands/completionRegistry.ts @@ -35,6 +35,13 @@ const REASONING_LEVELS: CompletionItem[] = [ { value: "off", description: "Disable reasoning", children: SAVE_FLAG }, ]; +const CHROME_CHANNELS: CompletionItem[] = [ + { value: "stable", description: "Target Chrome Stable", children: SAVE_FLAG }, + { value: "beta", description: "Target Chrome Beta", children: SAVE_FLAG }, + { value: "dev", description: "Target Chrome Dev", children: SAVE_FLAG }, + { value: "canary", description: "Target Chrome Canary", children: SAVE_FLAG }, +]; + export const COMMAND_COMPLETIONS: CompletionItem[] = [ { value: "/help", description: "Show available commands" }, { value: "/new", description: "Start a new session" }, @@ -55,6 +62,32 @@ export const COMMAND_COMPLETIONS: CompletionItem[] = [ description: "Set reasoning effort", children: REASONING_LEVELS, }, + { + value: "/chrome", + description: "Show or configure Chrome DevTools MCP", + children: [ + { value: "status", description: "Show Chrome MCP status" }, + { value: "on", description: "Enable Chrome MCP", children: SAVE_FLAG }, + { value: "off", description: "Disable Chrome MCP", children: SAVE_FLAG }, + { value: "auto", description: "Enable auto-connect mode", children: SAVE_FLAG }, + { + value: "url", + description: "Set an explicit Chrome browser URL", + children: [ + { + value: "", + description: "Remote debugging endpoint URL", + children: SAVE_FLAG, + }, + ], + }, + { + value: "channel", + description: "Set the Chrome release channel", + children: CHROME_CHANNELS, + }, + ], + }, { value: "/init", description: "Workspace initialization and migration", diff --git a/openplanter-desktop/frontend/src/commands/slash.test.ts b/openplanter-desktop/frontend/src/commands/slash.test.ts index 383cd37f..64799692 100644 --- a/openplanter-desktop/frontend/src/commands/slash.test.ts +++ b/openplanter-desktop/frontend/src/commands/slash.test.ts @@ -17,6 +17,12 @@ describe("dispatchSlashCommand", () => { ...originalState, provider: "anthropic", model: "claude-opus-4-6", + chromeMcpEnabled: true, + chromeMcpAutoConnect: true, + chromeMcpBrowserUrl: null, + chromeMcpChannel: "stable", + chromeMcpStatus: "ready", + chromeMcpStatusDetail: "Connected to Chrome.", sessionId: "20260101-120000-deadbeef", reasoningEffort: "medium", initGateState: "ready", @@ -71,6 +77,13 @@ describe("dispatchSlashCommand", () => { expect(result!.lines.some((l) => l.includes("Session:"))).toBe(true); }); + it("status shows chrome mcp state", async () => { + const result = await dispatchSlashCommand("/status"); + expect(result).not.toBeNull(); + expect(result!.lines.some((l) => l.includes("Chrome MCP:"))).toBe(true); + expect(result!.lines.some((l) => l.includes("Chrome runtime:"))).toBe(true); + }); + it("unknown command", async () => { const result = await dispatchSlashCommand("/foobar"); expect(result).not.toBeNull(); @@ -113,6 +126,19 @@ describe("dispatchSlashCommand", () => { ).toBe(true); }); + it("help includes chrome command", async () => { + const result = await dispatchSlashCommand("/help"); + expect(result).not.toBeNull(); + expect(result!.lines.some((l) => l.includes("/chrome"))).toBe(true); + }); + + it("chrome dispatches", async () => { + const result = await dispatchSlashCommand("/chrome"); + expect(result).not.toBeNull(); + expect(result!.action).toBe("handled"); + expect(result!.lines.some((l) => l.includes("Chrome MCP:"))).toBe(true); + }); + it("new creates session", async () => { __setHandler( "open_session", diff --git a/openplanter-desktop/frontend/src/commands/slash.ts b/openplanter-desktop/frontend/src/commands/slash.ts index 2636382f..a21e007e 100644 --- a/openplanter-desktop/frontend/src/commands/slash.ts +++ b/openplanter-desktop/frontend/src/commands/slash.ts @@ -2,6 +2,7 @@ import { appState } from "../state/store"; import { openSession } from "../api/invoke"; import { handleModelCommand, type CommandResult } from "./model"; +import { CHROME_USAGE, formatChromeStatusLines, handleChromeCommand } from "./chrome"; import { handleReasoningCommand } from "./reasoning"; import { handleInitCommand } from "./init"; @@ -31,6 +32,8 @@ export async function dispatchSlashCommand(input: string): Promise Set level (low, medium, high, off)", + " /chrome Show current Chrome DevTools MCP status", + ` ${CHROME_USAGE.slice(6)}`, " /init status Show workspace init status", " /init standard Initialize the current workspace", " /init migrate Open the migration init panel", @@ -80,6 +83,7 @@ export async function dispatchSlashCommand(input: string): Promiseprovider: ${s.provider || "auto"}`, `
model: ${s.model || "\u2014"}
`, + `
chrome mcp: ${s.chromeMcpStatus} (${s.chromeMcpChannel})
`, `
reasoning: ${s.reasoningEffort ?? "off"}
`, `
mode: ${s.recursive ? "recursive" : "flat"}
`, ].join(""); diff --git a/openplanter-desktop/frontend/src/components/ChatPane.test.ts b/openplanter-desktop/frontend/src/components/ChatPane.test.ts index 688b5099..a33d7840 100644 --- a/openplanter-desktop/frontend/src/components/ChatPane.test.ts +++ b/openplanter-desktop/frontend/src/components/ChatPane.test.ts @@ -30,7 +30,7 @@ describe("KEY_ARGS", () => { expect(KEY_ARGS["read_file"]).toBe("path"); expect(KEY_ARGS["run_shell"]).toBe("command"); expect(KEY_ARGS["web_search"]).toBe("query"); - expect(KEY_ARGS["fetch_url"]).toBe("url"); + expect(KEY_ARGS["fetch_url"]).toBe("urls"); }); }); @@ -279,6 +279,27 @@ describe("createChatPane", () => { document.body.removeChild(pane); }); + it("falls back to the first informative value for unknown tool args", () => { + const pane = createChatPane(); + document.body.appendChild(pane); + + window.dispatchEvent( + new CustomEvent("agent-delta", { detail: { kind: "tool_call_start", text: "chrome_click" } }) + ); + window.dispatchEvent( + new CustomEvent( + "agent-delta", + { detail: { kind: "tool_call_args", text: '{"selector": "#submit", "timeout": 5}' } }, + ) + ); + + const indicator = pane.querySelector(".activity-indicator"); + expect(indicator!.getAttribute("data-mode")).toBe("tool"); + expect(pane.querySelector(".activity-preview")!.textContent).toBe("#submit"); + + document.body.removeChild(pane); + }); + it("renders step summary on agent-step event", () => { const pane = createChatPane(); document.body.appendChild(pane); @@ -452,6 +473,22 @@ Trailing text.`; expect(msg!.textContent).not.toContain(""); }); + it("renders fallback key args for unknown tool calls in assistant messages", () => { + const pane = createChatPane(); + const content = ` +{"name": "chrome_evaluate", "arguments": {"expression": "document.title", "timeout": 10}} +`; + appState.update((s) => ({ + ...s, + messages: [makeMsg({ role: "assistant", content, isRendered: true })], + })); + const msg = pane.querySelector(".message.assistant.rendered"); + const toolBlock = msg!.querySelector(".tool-call-block"); + expect(toolBlock).not.toBeNull(); + expect(toolBlock!.querySelector(".tool-fn")!.textContent).toBe("chrome_evaluate"); + expect(toolBlock!.querySelector(".tool-arg")!.textContent).toContain("document.title"); + }); + it("renders tool_result XML as collapsible block in rendered assistant message", () => { const pane = createChatPane(); const content = ` diff --git a/openplanter-desktop/frontend/src/components/ChatPane.ts b/openplanter-desktop/frontend/src/components/ChatPane.ts index 734caf08..ce5339e7 100644 --- a/openplanter-desktop/frontend/src/components/ChatPane.ts +++ b/openplanter-desktop/frontend/src/components/ChatPane.ts @@ -2,24 +2,10 @@ import { appState, type ChatMessage, type StepToolCall } from "../state/store"; import { createInputBar } from "./InputBar"; import { parseAgentContent, stripToolXml, type ContentSegment } from "./contentParser"; +import { extractToolCallKeyArg, KEY_ARGS } from "./toolArgs"; import MarkdownIt from "markdown-it"; import hljs from "highlight.js"; -/** Key argument names for tool call display. */ -const KEY_ARGS: Record = { - read_file: "path", - write_file: "path", - edit_file: "path", - list_files: "directory", - run_shell: "command", - run_shell_bg: "command", - kill_shell_bg: "pid", - web_search: "query", - fetch_url: "url", - apply_patch: "path", - hashline_edit: "path", -}; - const md = new MarkdownIt({ html: false, linkify: true, @@ -34,16 +20,6 @@ const md = new MarkdownIt({ }, }); -/** Extract the key argument value from a partial JSON string. */ -function extractKeyArg(toolName: string, argsJson: string): string | null { - const keyName = KEY_ARGS[toolName]; - if (!keyName) return null; - // Try to extract "keyName": "value" from possibly-incomplete JSON - const regex = new RegExp(`"${keyName}"\\s*:\\s*"([^"]*)"?`); - const m = argsJson.match(regex); - return m ? m[1] : null; -} - /** Format elapsed milliseconds as a readable string. */ function formatElapsed(ms: number): string { if (ms < 1000) return `${ms}ms`; @@ -511,7 +487,7 @@ export function createChatPane(): HTMLElement { // Always re-extract key arg as more chunks arrive — partial JSON // grows with each chunk so the extracted value gets more complete. - const keyArg = extractKeyArg(currentToolName, toolArgsBuf); + const keyArg = extractToolCallKeyArg(currentToolName, toolArgsBuf); if (keyArg) { const current = stepToolCalls[stepToolCalls.length - 1]; if (current) current.keyArg = keyArg; @@ -592,5 +568,4 @@ export function createChatPane(): HTMLElement { return pane; } - export { KEY_ARGS }; diff --git a/openplanter-desktop/frontend/src/components/contentParser.test.ts b/openplanter-desktop/frontend/src/components/contentParser.test.ts index ccb888a0..e62788a9 100644 --- a/openplanter-desktop/frontend/src/components/contentParser.test.ts +++ b/openplanter-desktop/frontend/src/components/contentParser.test.ts @@ -86,7 +86,19 @@ Environment confirmed.`; expect(result[0]).toMatchObject({ type: "tool_call", name: "custom_tool", - keyArg: "", + keyArg: "stuff", + }); + }); + + it("falls back to the first informative array or number for unknown tools", () => { + const content = ` +{"name": "custom_tool", "arguments": {"links": ["https://a.test", "https://b.test"], "limit": 3}} +`; + const result = parseAgentContent(content); + expect(result[0]).toMatchObject({ + type: "tool_call", + name: "custom_tool", + keyArg: "https://a.test, https://b.test", }); }); diff --git a/openplanter-desktop/frontend/src/components/contentParser.ts b/openplanter-desktop/frontend/src/components/contentParser.ts index eea4f95f..fec28dd7 100644 --- a/openplanter-desktop/frontend/src/components/contentParser.ts +++ b/openplanter-desktop/frontend/src/components/contentParser.ts @@ -1,19 +1,5 @@ /** Parse and XML blocks from agent content. */ - -/** Key argument names for tool call display (mirrors ChatPane's KEY_ARGS). */ -const KEY_ARGS: Record = { - read_file: "path", - write_file: "path", - edit_file: "path", - list_files: "directory", - run_shell: "command", - run_shell_bg: "command", - kill_shell_bg: "pid", - web_search: "query", - fetch_url: "url", - apply_patch: "path", - hashline_edit: "path", -}; +import { getToolCallKeyArg } from "./toolArgs"; export type ContentSegment = | { type: "text"; text: string } @@ -67,8 +53,7 @@ function parseToolCall(json: string): ContentSegment { const obj = JSON.parse(json); const name: string = obj.name ?? "unknown"; const args = obj.arguments ?? {}; - const keyName = KEY_ARGS[name]; - const keyArg = keyName && typeof args[keyName] === "string" ? args[keyName] : ""; + const keyArg = getToolCallKeyArg(name, args); return { type: "tool_call", name, keyArg, rawArgs: JSON.stringify(args) }; } catch { return { type: "tool_call", name: "unknown", keyArg: "", rawArgs: json }; diff --git a/openplanter-desktop/frontend/src/components/toolArgs.ts b/openplanter-desktop/frontend/src/components/toolArgs.ts new file mode 100644 index 00000000..9fb110e4 --- /dev/null +++ b/openplanter-desktop/frontend/src/components/toolArgs.ts @@ -0,0 +1,160 @@ +/** Shared helpers for rendering compact tool argument previews. */ + +export const KEY_ARGS: Record = { + read_file: "path", + read_image: "path", + audio_transcribe: "path", + write_file: "path", + edit_file: "path", + hashline_edit: "path", + apply_patch: "patch", + list_files: "glob", + search_files: "query", + repo_map: "glob", + run_shell: "command", + run_shell_bg: "command", + check_shell_bg: "job_id", + kill_shell_bg: "job_id", + web_search: "query", + fetch_url: "urls", + subtask: "objective", + execute: "objective", + think: "note", +}; + +interface IndexedCandidate { + index: number; + value: string; +} + +function normalizePreviewValue(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || null; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + if (Array.isArray(value)) { + const items = value.flatMap((item) => { + if (typeof item === "string") { + const trimmed = item.trim(); + return trimmed ? [trimmed] : []; + } + if (typeof item === "number" && Number.isFinite(item)) { + return [String(item)]; + } + return []; + }); + return items.length > 0 ? items.join(", ") : null; + } + + return null; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function collectRegexCandidates( + source: string, + regex: RegExp, + pickValue: (match: RegExpMatchArray) => string | null, +): IndexedCandidate[] { + const candidates: IndexedCandidate[] = []; + for (const match of source.matchAll(regex)) { + const value = pickValue(match)?.trim(); + if (value) { + candidates.push({ + index: match.index ?? Number.MAX_SAFE_INTEGER, + value, + }); + } + } + return candidates; +} + +function collectCandidatesForKey(source: string, key: string): IndexedCandidate[] { + const escapedKey = escapeRegExp(key); + const stringRegex = new RegExp(`"${escapedKey}"\\s*:\\s*"([^"]*)`, "g"); + const arrayRegex = new RegExp(`"${escapedKey}"\\s*:\\s*\\[([^\\]]*)`, "g"); + const numberRegex = new RegExp(`"${escapedKey}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`, "g"); + + return [ + ...collectRegexCandidates(source, stringRegex, (match) => match[1] ?? null), + ...collectRegexCandidates(source, arrayRegex, (match) => { + const items = [...(match[1] ?? "").matchAll(/"([^"]*)/g)] + .map((item) => item[1]?.trim() ?? "") + .filter(Boolean); + return items.length > 0 ? items.join(", ") : null; + }), + ...collectRegexCandidates(source, numberRegex, (match) => match[1] ?? null), + ].sort((a, b) => a.index - b.index); +} + +function collectFallbackCandidates(source: string): IndexedCandidate[] { + return [ + ...collectRegexCandidates( + source, + /"([^"]+)"\s*:\s*"([^"]*)/g, + (match) => match[2] ?? null, + ), + ...collectRegexCandidates( + source, + /"([^"]+)"\s*:\s*\[([^\]]*)/g, + (match) => { + const items = [...(match[2] ?? "").matchAll(/"([^"]*)/g)] + .map((item) => item[1]?.trim() ?? "") + .filter(Boolean); + return items.length > 0 ? items.join(", ") : null; + }, + ), + ...collectRegexCandidates( + source, + /"([^"]+)"\s*:\s*(-?\d+(?:\.\d+)?)/g, + (match) => match[2] ?? null, + ), + ].sort((a, b) => a.index - b.index); +} + +/** Return the best compact preview for a parsed tool argument object. */ +export function getToolCallKeyArg(toolName: string, args: unknown): string { + if (!args || typeof args !== "object" || Array.isArray(args)) { + return ""; + } + + const entries = Object.entries(args as Record); + const preferredKey = KEY_ARGS[toolName]; + + if (preferredKey) { + const preferredValue = normalizePreviewValue((args as Record)[preferredKey]); + if (preferredValue) { + return preferredValue; + } + } + + for (const [, value] of entries) { + const preview = normalizePreviewValue(value); + if (preview) { + return preview; + } + } + + return ""; +} + +/** Best-effort extraction from a partial JSON argument string during streaming. */ +export function extractToolCallKeyArg(toolName: string, argsJson: string): string | null { + const preferredKey = KEY_ARGS[toolName]; + if (preferredKey) { + const preferred = collectCandidatesForKey(argsJson, preferredKey)[0]; + if (preferred) { + return preferred.value; + } + } + + const fallback = collectFallbackCandidates(argsJson)[0]; + return fallback?.value ?? null; +} diff --git a/openplanter-desktop/frontend/src/main.ts b/openplanter-desktop/frontend/src/main.ts index 42e642a0..4370b456 100644 --- a/openplanter-desktop/frontend/src/main.ts +++ b/openplanter-desktop/frontend/src/main.ts @@ -38,6 +38,14 @@ async function init() { ...s, provider: config.provider, model: config.model, + chromeMcpEnabled: config.chrome_mcp_enabled, + chromeMcpAutoConnect: config.chrome_mcp_auto_connect, + chromeMcpBrowserUrl: config.chrome_mcp_browser_url, + chromeMcpChannel: config.chrome_mcp_channel, + chromeMcpConnectTimeoutSec: config.chrome_mcp_connect_timeout_sec, + chromeMcpRpcTimeoutSec: config.chrome_mcp_rpc_timeout_sec, + chromeMcpStatus: config.chrome_mcp_status, + chromeMcpStatusDetail: config.chrome_mcp_status_detail, sessionId: config.session_id, reasoningEffort: config.reasoning_effort, recursive: config.recursive, @@ -72,6 +80,7 @@ async function init() { content: [ `provider: ${provider || "auto"}`, `model: ${model || "—"}`, + `chrome mcp: ${state.chromeMcpStatus}`, `reasoning: ${reasoningLabel}`, `mode: ${modeLabel}`, `workspace: ${state.workspace || "."}`, diff --git a/openplanter-desktop/frontend/src/state/store.ts b/openplanter-desktop/frontend/src/state/store.ts index 918598ab..00ab3e58 100644 --- a/openplanter-desktop/frontend/src/state/store.ts +++ b/openplanter-desktop/frontend/src/state/store.ts @@ -70,6 +70,14 @@ export interface ChatMessage { export interface AppState { provider: string; model: string; + chromeMcpEnabled: boolean; + chromeMcpAutoConnect: boolean; + chromeMcpBrowserUrl: string | null; + chromeMcpChannel: string; + chromeMcpConnectTimeoutSec: number; + chromeMcpRpcTimeoutSec: number; + chromeMcpStatus: string; + chromeMcpStatusDetail: string; sessionId: string | null; inputTokens: number; outputTokens: number; @@ -99,6 +107,14 @@ export interface AppState { export const appState = new Store({ provider: "", model: "", + chromeMcpEnabled: false, + chromeMcpAutoConnect: true, + chromeMcpBrowserUrl: null, + chromeMcpChannel: "stable", + chromeMcpConnectTimeoutSec: 15, + chromeMcpRpcTimeoutSec: 45, + chromeMcpStatus: "disabled", + chromeMcpStatusDetail: "Chrome DevTools MCP is disabled.", sessionId: null, inputTokens: 0, outputTokens: 0, diff --git a/tests/test_chrome_mcp.py b/tests/test_chrome_mcp.py new file mode 100644 index 00000000..dc1ad5ca --- /dev/null +++ b/tests/test_chrome_mcp.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import os +import stat +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from agent.chrome_mcp import ( + ChromeMcpError, + ChromeMcpManager, + acquire_shared_manager, + shutdown_all_shared_managers, +) + + +FAKE_MCP_SERVER = """#!/usr/bin/env python3 +import json +import sys + +TOOLS = [ + { + "name": "navigate_page", + "description": "Navigate the page", + "inputSchema": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + "additionalProperties": False, + }, + }, + { + "name": "take_screenshot", + "description": "Take a screenshot", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + }, + }, +] + +for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + payload = json.loads(line) + method = payload.get("method") + request_id = payload.get("id") + if method == "initialize" and request_id is not None: + sys.stdout.write(json.dumps({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2025-11-25", + "serverInfo": {"name": "fake-chrome-mcp", "version": "1.0"}, + }, + }) + "\\n") + sys.stdout.flush() + continue + if method == "tools/list" and request_id is not None: + sys.stdout.write(json.dumps({ + "jsonrpc": "2.0", + "id": request_id, + "result": {"tools": TOOLS}, + }) + "\\n") + sys.stdout.flush() + continue + if method == "tools/call" and request_id is not None: + params = payload.get("params") or {} + name = params.get("name") + if name == "take_screenshot": + result = { + "content": [ + {"type": "text", "text": "Screenshot captured."}, + {"type": "image", "data": "ZmFrZS1pbWFnZQ==", "mimeType": "image/png"}, + ] + } + else: + result = { + "content": [ + {"type": "text", "text": f"Called {name}"}, + ] + } + sys.stdout.write(json.dumps({ + "jsonrpc": "2.0", + "id": request_id, + "result": result, + }) + "\\n") + sys.stdout.flush() +""" + + +def _write_fake_launcher(tmpdir: str) -> Path: + launcher = Path(tmpdir) / "fake_npx.py" + launcher.write_text(FAKE_MCP_SERVER, encoding="utf-8") + launcher.chmod(launcher.stat().st_mode | stat.S_IXUSR) + return launcher + + +class ChromeMcpManagerTests(unittest.TestCase): + def tearDown(self) -> None: + shutdown_all_shared_managers() + + def test_initialize_list_tools_and_call_tool(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + launcher = _write_fake_launcher(tmpdir) + with patch.dict( + os.environ, + { + "OPENPLANTER_CHROME_MCP_COMMAND": str(launcher), + "OPENPLANTER_CHROME_MCP_PACKAGE": "ignored-package", + }, + clear=False, + ): + manager = ChromeMcpManager( + enabled=True, + auto_connect=True, + browser_url=None, + channel="stable", + connect_timeout_sec=3, + rpc_timeout_sec=3, + ) + tools = manager.list_tools(force_refresh=True) + self.assertEqual([tool.name for tool in tools], ["navigate_page", "take_screenshot"]) + + result = manager.call_tool("navigate_page", {"url": "https://example.com"}) + self.assertIn("Called navigate_page", result.content) + self.assertFalse(result.is_error) + manager.shutdown() + + def test_call_tool_parses_image_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + launcher = _write_fake_launcher(tmpdir) + with patch.dict( + os.environ, + { + "OPENPLANTER_CHROME_MCP_COMMAND": str(launcher), + "OPENPLANTER_CHROME_MCP_PACKAGE": "ignored-package", + }, + clear=False, + ): + manager = ChromeMcpManager( + enabled=True, + auto_connect=True, + browser_url=None, + channel="stable", + connect_timeout_sec=3, + rpc_timeout_sec=3, + ) + result = manager.call_tool("take_screenshot", {}) + self.assertIn("Screenshot captured.", result.content) + self.assertIsNotNone(result.image) + assert result.image is not None + self.assertEqual(result.image.media_type, "image/png") + self.assertEqual(result.image.base64_data, "ZmFrZS1pbWFnZQ==") + manager.shutdown() + + def test_missing_attach_mode_reports_unavailable(self) -> None: + manager = ChromeMcpManager( + enabled=True, + auto_connect=False, + browser_url=None, + channel="stable", + connect_timeout_sec=1, + rpc_timeout_sec=1, + ) + with self.assertRaises(ChromeMcpError): + manager.list_tools() + status = manager.status_snapshot() + self.assertEqual(status.status, "unavailable") + self.assertIn("chrome_mcp_browser_url", status.detail) + + def test_shared_manager_registry_reuses_instances(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + launcher = _write_fake_launcher(tmpdir) + with patch.dict( + os.environ, + { + "OPENPLANTER_CHROME_MCP_COMMAND": str(launcher), + "OPENPLANTER_CHROME_MCP_PACKAGE": "ignored-package", + }, + clear=False, + ): + first = acquire_shared_manager( + enabled=True, + auto_connect=True, + browser_url=None, + channel="stable", + connect_timeout_sec=3, + rpc_timeout_sec=3, + ) + second = acquire_shared_manager( + enabled=True, + auto_connect=True, + browser_url=None, + channel="stable", + connect_timeout_sec=3, + rpc_timeout_sec=3, + ) + self.assertIs(first, second) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_engine.py b/tests/test_engine.py index 4e8c58bc..3fca7972 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -9,6 +9,7 @@ from unittest.mock import patch from conftest import _tc +from agent.chrome_mcp import ChromeMcpCallResult from agent.config import AgentConfig from agent.engine import RLMEngine from agent.prompts import build_system_prompt as _build_system_prompt @@ -17,6 +18,53 @@ class EngineTests(unittest.TestCase): + def test_dynamic_tool_defs_are_merged_for_main_loop(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + cfg = AgentConfig(workspace=root, max_depth=1, max_steps_per_call=2) + tools = WorkspaceTools(root=root) + model = ScriptedModel(scripted_turns=[ModelTurn(text="done", stop_reason="end_turn")]) + with patch.object( + tools, + "get_chrome_mcp_tool_defs", + return_value=[ + { + "name": "navigate_page", + "description": "Navigate Chrome", + "parameters": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + "additionalProperties": False, + }, + } + ], + ): + engine = RLMEngine(model=model, tools=tools, config=cfg) + names = [tool["name"] for tool in engine._build_tool_defs(include_subtask=True)] + self.assertIn("navigate_page", names) + + def test_dynamic_tool_calls_fall_through_to_chrome_manager(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + cfg = AgentConfig(workspace=root, max_depth=1, max_steps_per_call=4) + tools = WorkspaceTools(root=root) + model = ScriptedModel( + scripted_turns=[ + ModelTurn(tool_calls=[_tc("navigate_page", url="https://example.com")]), + ModelTurn(text="done", stop_reason="end_turn"), + ] + ) + with patch.object(tools, "get_chrome_mcp_tool_defs", return_value=[]), patch.object( + tools, + "try_execute_dynamic_tool", + return_value=ChromeMcpCallResult(content="Navigated to https://example.com"), + ) as mocked: + engine = RLMEngine(model=model, tools=tools, config=cfg) + result = engine.solve("navigate using Chrome MCP") + self.assertEqual(result, "done") + mocked.assert_called_once() + def test_write_and_read_then_final(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/tests/test_settings.py b/tests/test_settings.py index 1f1af13a..3c6f3253 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,7 +8,12 @@ from agent.builder import _validate_model_provider, infer_provider_for_model from agent.credentials import CredentialBundle from agent.model import ModelError -from agent.settings import PersistentSettings, SettingsStore, normalize_reasoning_effort +from agent.settings import ( + PersistentSettings, + SettingsStore, + normalize_chrome_mcp_channel, + normalize_reasoning_effort, +) from agent.tui import SLASH_COMMANDS, _compute_suggestions @@ -26,6 +31,27 @@ def test_settings_roundtrip(self) -> None: self.assertEqual(loaded.default_model, "gpt-5.2") self.assertEqual(loaded.default_reasoning_effort, "high") + def test_chrome_mcp_settings_roundtrip(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + store = SettingsStore(workspace=root, session_root_dir=".openplanter") + settings = PersistentSettings( + chrome_mcp_enabled=True, + chrome_mcp_auto_connect=False, + chrome_mcp_browser_url="http://127.0.0.1:9222", + chrome_mcp_channel="beta", + chrome_mcp_connect_timeout_sec=21, + chrome_mcp_rpc_timeout_sec=61, + ) + store.save(settings) + loaded = store.load() + self.assertTrue(loaded.chrome_mcp_enabled) + self.assertFalse(loaded.chrome_mcp_auto_connect) + self.assertEqual(loaded.chrome_mcp_browser_url, "http://127.0.0.1:9222") + self.assertEqual(loaded.chrome_mcp_channel, "beta") + self.assertEqual(loaded.chrome_mcp_connect_timeout_sec, 21) + self.assertEqual(loaded.chrome_mcp_rpc_timeout_sec, 61) + def test_normalize_reasoning_effort(self) -> None: self.assertEqual(normalize_reasoning_effort("LOW"), "low") self.assertEqual(normalize_reasoning_effort(" medium "), "medium") @@ -33,6 +59,12 @@ def test_normalize_reasoning_effort(self) -> None: with self.assertRaises(ValueError): normalize_reasoning_effort("extreme") + def test_normalize_chrome_channel(self) -> None: + self.assertEqual(normalize_chrome_mcp_channel("BETA"), "beta") + self.assertIsNone(normalize_chrome_mcp_channel("")) + with self.assertRaises(ValueError): + normalize_chrome_mcp_channel("nightly") + def test_per_provider_model_roundtrip(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -151,6 +183,10 @@ def test_slash_r_matches_reasoning(self) -> None: matches, _ = _compute_suggestions("/r") self.assertIn("/reasoning", matches) + def test_slash_c_matches_chrome(self) -> None: + matches, _ = _compute_suggestions("/ch") + self.assertIn("/chrome", matches) + class InferProviderTests(unittest.TestCase): def test_claude_is_anthropic(self) -> None: diff --git a/tests/test_tool_defs.py b/tests/test_tool_defs.py index a985725b..09b74089 100644 --- a/tests/test_tool_defs.py +++ b/tests/test_tool_defs.py @@ -71,6 +71,45 @@ def test_default_includes_subtask(self) -> None: names = [d["name"] for d in defs] self.assertIn("subtask", names) + def test_dynamic_defs_are_merged(self) -> None: + defs = get_tool_definitions( + include_subtask=False, + dynamic_defs=[ + { + "name": "navigate_page", + "description": "Navigate Chrome", + "parameters": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + "additionalProperties": False, + }, + } + ], + ) + names = [d["name"] for d in defs] + self.assertIn("navigate_page", names) + + def test_dynamic_defs_do_not_override_static_names(self) -> None: + defs = get_tool_definitions( + include_subtask=False, + dynamic_defs=[ + { + "name": "read_file", + "description": "override", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + }, + } + ], + ) + matches = [d for d in defs if d["name"] == "read_file"] + self.assertEqual(len(matches), 1) + self.assertIn("Read the contents", matches[0]["description"]) + class MakeStrictParametersTests(unittest.TestCase): """Tests for _make_strict_parameters().""" diff --git a/tests/test_tui_repl.py b/tests/test_tui_repl.py index cd331fde..cda7e98d 100644 --- a/tests/test_tui_repl.py +++ b/tests/test_tui_repl.py @@ -8,6 +8,7 @@ import pytest +from agent.chrome_mcp import ChromeMcpStatus from agent.config import AgentConfig from agent.settings import SettingsStore from agent.tui import ChatContext, RichREPL, _queue_prompt_style, dispatch_slash_command @@ -261,6 +262,14 @@ def test_exit_command_exits(self, tmp_path): repl.run() repl.ctx.runtime.solve.assert_not_called() + +class TestRunLoopMore: + def _make_repl(self, tmp_path): + ctx = _make_ctx(tmp_path) + repl = RichREPL(ctx) + repl.console = MagicMock() + return repl + def test_help_command_handled(self, tmp_path): """The /help command should be handled without running the agent, then continue.""" repl = self._make_repl(tmp_path) @@ -323,6 +332,44 @@ def fake_solve(objective, on_event=None, on_step=None, on_content_delta=None): assert agent_ran.is_set() +class TestChromeSlashCommand: + def test_status_renders_runtime_state(self, tmp_path): + ctx = _make_ctx(tmp_path) + ctx.cfg.chrome_mcp_enabled = True + ctx.cfg.chrome_mcp_auto_connect = True + ctx.cfg.chrome_mcp_channel = "stable" + ctx.runtime.engine.tools.chrome_mcp_status.return_value = ChromeMcpStatus( + status="ready", + detail="Chrome DevTools MCP ready with 2 tool(s).", + tool_count=2, + ) + lines: list[str] = [] + result = dispatch_slash_command("/chrome status", ctx, emit=lines.append) + assert result == "handled" + assert any("Chrome MCP:" in line for line in lines) + assert any("ready" in line for line in lines) + + def test_auto_rebuilds_engine_and_persists(self, tmp_path): + ctx = _make_ctx(tmp_path) + rebuilt_engine = MagicMock() + rebuilt_engine.tools.chrome_mcp_status.return_value = ChromeMcpStatus( + status="ready", + detail="Chrome DevTools MCP ready with 3 tool(s).", + tool_count=3, + ) + lines: list[str] = [] + with patch("agent.builder.build_engine", return_value=rebuilt_engine): + result = dispatch_slash_command("/chrome auto --save", ctx, emit=lines.append) + assert result == "handled" + assert ctx.cfg.chrome_mcp_enabled is True + assert ctx.cfg.chrome_mcp_auto_connect is True + assert ctx.cfg.chrome_mcp_browser_url is None + saved = ctx.settings_store.load() + assert saved.chrome_mcp_enabled is True + assert saved.chrome_mcp_auto_connect is True + assert "Saved as workspace default." in lines + + # --------------------------------------------------------------------------- # dispatch_slash_command # --------------------------------------------------------------------------- From d63a56797a349645ff0f9ef3b97ef1b275d353cb Mon Sep 17 00:00:00 2001 From: Drake Date: Mon, 16 Mar 2026 17:47:52 -0400 Subject: [PATCH 2/3] Fix auto provider fallback after cleanup --- agent/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/__main__.py b/agent/__main__.py index 069a5fd8..47b94d76 100644 --- a/agent/__main__.py +++ b/agent/__main__.py @@ -201,7 +201,7 @@ def _resolve_provider(requested: str, creds: CredentialBundle) -> str: return "openrouter" if creds.cerebras_api_key: return "cerebras" - return "openai" + return "anthropic" def _print_models(cfg: AgentConfig, requested_provider: str) -> int: From 131089f4093f94b521e8b6372d9e8bd2c8e521b2 Mon Sep 17 00:00:00 2001 From: Drake Date: Mon, 16 Mar 2026 18:10:21 -0400 Subject: [PATCH 3/3] Restore rate limit error types after stack cleanup --- agent/model.py | 17 +++++++++ .../crates/op-core/src/model/mod.rs | 18 ++++++++++ .../op-core/tests/test_model_streaming.rs | 36 ++++++++++++++----- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/agent/model.py b/agent/model.py index 30bc3ff7..a029dae1 100644 --- a/agent/model.py +++ b/agent/model.py @@ -15,6 +15,23 @@ class ModelError(RuntimeError): pass +class RateLimitError(ModelError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + provider_code: str | int | None = None, + body: str = "", + retry_after_sec: float | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.provider_code = provider_code + self.body = body + self.retry_after_sec = retry_after_sec + + # --------------------------------------------------------------------------- # Core data types # --------------------------------------------------------------------------- diff --git a/openplanter-desktop/crates/op-core/src/model/mod.rs b/openplanter-desktop/crates/op-core/src/model/mod.rs index 4f2781ec..81b04ca3 100644 --- a/openplanter-desktop/crates/op-core/src/model/mod.rs +++ b/openplanter-desktop/crates/op-core/src/model/mod.rs @@ -8,6 +8,24 @@ use serde::{Deserialize, Serialize}; use crate::events::DeltaEvent; use tokio_util::sync::CancellationToken; +/// Structured model error for provider rate limiting. +#[derive(Debug, Clone)] +pub struct RateLimitError { + pub message: String, + pub status_code: Option, + pub provider_code: Option, + pub body: String, + pub retry_after_sec: Option, +} + +impl std::fmt::Display for RateLimitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for RateLimitError {} + /// A single tool call returned by the model. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { diff --git a/openplanter-desktop/crates/op-core/tests/test_model_streaming.rs b/openplanter-desktop/crates/op-core/tests/test_model_streaming.rs index 5e792de0..2b8eab1a 100644 --- a/openplanter-desktop/crates/op-core/tests/test_model_streaming.rs +++ b/openplanter-desktop/crates/op-core/tests/test_model_streaming.rs @@ -13,7 +13,7 @@ use axum::routing::post; use axum::Router; use tokio_util::sync::CancellationToken; -use op_core::events::{DeltaEvent, DeltaKind}; +use op_core::events::{CompletionMeta, DeltaEvent, DeltaKind, LoopMetrics}; use op_core::model::openai::OpenAIModel; use op_core::model::anthropic::AnthropicModel; use op_core::model::{BaseModel, Message}; @@ -448,7 +448,12 @@ async fn test_solve_with_mock_anthropic() { fn emit_step(&self, event: StepEvent) { self.events.lock().unwrap().push(Ev::Step(event)); } - fn emit_complete(&self, result: &str) { + fn emit_complete( + &self, + result: &str, + _: Option, + _: Option, + ) { self.events.lock().unwrap().push(Ev::Complete(result.to_string())); } fn emit_error(&self, message: &str) { @@ -539,7 +544,12 @@ async fn test_solve_with_mock_openai() { fn emit_step(&self, event: StepEvent) { self.events.lock().unwrap().push(Ev2::Step(event)); } - fn emit_complete(&self, result: &str) { + fn emit_complete( + &self, + result: &str, + _: Option, + _: Option, + ) { self.events.lock().unwrap().push(Ev2::Complete(result.to_string())); } fn emit_error(&self, message: &str) { @@ -619,7 +629,7 @@ async fn test_solve_http_error_emits_error() { fn emit_trace(&self, _: &str) {} fn emit_delta(&self, _: DeltaEvent) {} fn emit_step(&self, _: StepEvent) {} - fn emit_complete(&self, _: &str) {} + fn emit_complete(&self, _: &str, _: Option, _: Option) {} fn emit_error(&self, msg: &str) { self.errors.lock().unwrap().push(msg.to_string()); } @@ -664,7 +674,7 @@ async fn test_solve_cancel_emits_cancelled() { fn emit_trace(&self, _: &str) {} fn emit_delta(&self, _: DeltaEvent) {} fn emit_step(&self, _: StepEvent) {} - fn emit_complete(&self, _: &str) {} + fn emit_complete(&self, _: &str, _: Option, _: Option) {} fn emit_error(&self, msg: &str) { self.events.lock().unwrap().push(msg.to_string()); } @@ -707,7 +717,12 @@ async fn test_solve_demo_mode_bypasses_llm() { fn emit_trace(&self, _: &str) {} fn emit_delta(&self, _: DeltaEvent) {} fn emit_step(&self, _: StepEvent) {} - fn emit_complete(&self, result: &str) { + fn emit_complete( + &self, + result: &str, + _: Option, + _: Option, + ) { self.events.lock().unwrap().push(result.to_string()); } fn emit_error(&self, msg: &str) { @@ -746,7 +761,7 @@ async fn test_solve_missing_key_emits_error() { fn emit_trace(&self, _: &str) {} fn emit_delta(&self, _: DeltaEvent) {} fn emit_step(&self, _: StepEvent) {} - fn emit_complete(&self, _: &str) {} + fn emit_complete(&self, _: &str, _: Option, _: Option) {} fn emit_error(&self, msg: &str) { self.errors.lock().unwrap().push(msg.to_string()); } @@ -872,7 +887,12 @@ async fn test_solve_multi_step_agentic_loop() { fn emit_step(&self, event: StepEvent) { self.events.lock().unwrap().push(Ev3::Step(event)); } - fn emit_complete(&self, result: &str) { + fn emit_complete( + &self, + result: &str, + _: Option, + _: Option, + ) { self.events.lock().unwrap().push(Ev3::Complete(result.to_string())); } fn emit_error(&self, message: &str) {