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:
139
src/jarvis/tools/builtin/control_browser.py
Normal file
139
src/jarvis/tools/builtin/control_browser.py
Normal 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 "완료했습니다."
|
||||
@@ -21,6 +21,7 @@ from .builtin.weather import WeatherTool
|
||||
from .builtin.stop import StopTool
|
||||
from .builtin.tool_search import ToolSearchTool
|
||||
from .builtin.browse_and_play import BrowseAndPlayTool
|
||||
from .builtin.control_browser import ControlBrowserTool
|
||||
from .builtin.set_broadcast import SetBroadcastTool
|
||||
from .types import ToolExecutionResult
|
||||
from ..config import Settings
|
||||
@@ -42,6 +43,7 @@ BUILTIN_TOOLS = {
|
||||
"stop": StopTool(),
|
||||
"toolSearchTool": ToolSearchTool(),
|
||||
"browseAndPlay": BrowseAndPlayTool(),
|
||||
"controlBrowser": ControlBrowserTool(),
|
||||
"setBroadcast": SetBroadcastTool(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user