feat: controlBrowser tool — human-operable on-screen Chrome

Adds a general browser-control tool (navigate to any site, list/open/close/
switch tabs, close popups, click, type, scroll, screenshot) for the Go-Live
Chrome, on top of the existing CDP + xdotool human-input layer (visible
cursor, char-by-char typing). Closes the gap where "open Naver" had no tool
and the model confabulated success. Also adds a system-prompt rule against
claiming actions no tool actually performed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-14 02:22:36 +09:00
parent 927d59f805
commit 3d1e56f60f
4 changed files with 338 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
"""Operate the on-screen (Go-Live) Chrome like a person would.
Only meaningful in screen-share mode (``STREAM_BROWSER`` true): it drives the
on-screen Chrome via the Node CDP helper (``chrome-control.mjs``) so every
action is visible on the broadcast. Pointer moves, clicks and typing are real
xdotool input (visible cursor, char-by-char typing), never synthetic DOM
events.
This is the general browser-control tool (navigate to any site, manage
windows/tabs, click, type, scroll, screenshot). ``browseAndPlay`` remains the
YouTube-playback shortcut.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional
from ..base import Tool, ToolContext
from ..types import ToolExecutionResult
from ...debug import debug_log
# .../owner/src/jarvis/tools/builtin/control_browser.py -> parents[4] == .../owner
_REPO_ROOT = Path(__file__).resolve().parents[4]
_NODE_SCRIPT = _REPO_ROOT / "bot" / "scripts" / "stream-test" / "chrome-control.mjs"
# Actions that don't change anything (safe, fast, no human-input latency).
_READ_ACTIONS = {"status", "listTabs"}
class ControlBrowserTool(Tool):
"""Drive the on-screen Chrome: navigate, manage tabs, click, type, scroll."""
@property
def name(self) -> str:
return "controlBrowser"
@property
def description(self) -> str:
return (
"Operate the on-screen Chrome shown on the broadcast like a person: open a "
"website/URL, list/open/close/switch tabs, close popups, click an element, "
"type text, scroll, or screenshot. Use whenever the user asks to go to a site "
"(e.g. 'open Naver'), check what's on screen, or interact with the page. "
"Only available in screen-share mode. Do NOT claim you did any of this unless "
"this tool returns success."
)
@property
def inputSchema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"status", "listTabs", "navigate", "newTab", "closeTab",
"activateTab", "closePopups", "click", "type", "scroll",
"pressKey", "screenshot",
],
"description": "What to do in the browser.",
},
"url": {"type": "string", "description": "Target URL/site for navigate/newTab (e.g. 'naver.com')."},
"index": {"type": "integer", "description": "Tab index for closeTab/activateTab (from listTabs)."},
"selector": {"type": "string", "description": "CSS selector for click/type."},
"text": {"type": "string", "description": "Text to type."},
"key": {"type": "string", "description": "Key to press, e.g. 'Return', 'Escape'."},
"dir": {"type": "string", "description": "Scroll direction: 'down' or 'up'."},
},
"required": ["action"],
}
def run(self, args: Optional[Dict[str, Any]], context: ToolContext) -> ToolExecutionResult:
cfg = context.cfg
if not getattr(cfg, "stream_browser", True):
return ToolExecutionResult(
success=False,
reply_text="화면 공유 모드(STREAM_BROWSER=true)에서만 브라우저를 제어할 수 있습니다.",
)
if not _NODE_SCRIPT.exists():
return ToolExecutionResult(success=False, reply_text="브라우저 제어 도구를 찾을 수 없습니다.")
if not args or not isinstance(args, dict):
return ToolExecutionResult(success=False, reply_text="수행할 동작(action)을 알려주세요.")
action = str(args.get("action", "")).strip()
if not action:
return ToolExecutionResult(success=False, reply_text="수행할 동작(action)을 알려주세요.")
# Pass the whole arg dict through as the command (the Node side reads the
# fields it needs per action).
command = json.dumps(args, ensure_ascii=False)
context.user_print(f"🖱️ 브라우저 제어: {action}")
debug_log(f" 🖱️ controlBrowser {command[:120]}", "tools")
# Human-input actions need time for the visible cursor move + char typing.
timeout = 25 if action in _READ_ACTIONS else 90
try:
proc = subprocess.run(
["node", str(_NODE_SCRIPT), command],
capture_output=True,
text=True,
timeout=timeout,
env={**os.environ, "CDP_PORT": os.environ.get("CDP_PORT", "9222")},
)
data = json.loads((proc.stdout or "").strip() or "{}")
except Exception as e:
return ToolExecutionResult(success=False, reply_text=f"브라우저 제어에 실패했습니다: {e}")
if not data.get("ok"):
return ToolExecutionResult(
success=False, reply_text=f"브라우저 제어에 실패했습니다: {data.get('error', 'unknown')}"
)
# Return a factual summary of what ACTUALLY happened so the reply engine
# describes the real outcome and doesn't confabulate.
summary = self._summarise(action, args, data)
return ToolExecutionResult(success=True, reply_text=summary)
@staticmethod
def _summarise(action: str, args: Dict[str, Any], data: Dict[str, Any]) -> str:
if action == "navigate":
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
if action in ("status", "listTabs"):
tabs = data.get("tabs", [])
lines = "\n".join(f" [{t['index']}] {t.get('title') or t['url']}" for t in tabs) or " (탭 없음)"
return f"브라우저 탭 {len(tabs)}개:\n{lines}"
if action == "newTab":
return f"새 탭을 열었습니다 (index {data.get('index')})."
if action == "closeTab":
return f"{data.get('closed')}번을 닫았습니다 (남은 탭 {data.get('remaining')}개)."
if action == "activateTab":
return f"{data.get('active')}번으로 전환했습니다."
if action == "closePopups":
return f"팝업/빈 탭 {data.get('closed')}개를 닫았습니다."
if action == "screenshot":
return f"화면을 캡처했습니다: {data.get('path')}"
return "완료했습니다."