Files
javis_bot/src/jarvis/tools/registry.py
javis-bot 3d1e56f60f 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>
2026-06-14 02:22:36 +09:00

376 lines
14 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Dict, Any, Tuple, List
import sys
import re
import requests
import threading
from datetime import datetime, timezone, timedelta
from pathlib import Path
import os
from .builtin.screenshot import ScreenshotTool
from .builtin.web_search import WebSearchTool
from .builtin.local_files import LocalFilesTool
from .builtin.fetch_web_page import FetchWebPageTool
from .builtin.nutrition.log_meal import LogMealTool
from .builtin.nutrition.fetch_meals import FetchMealsTool
from .builtin.nutrition.delete_meal import DeleteMealTool
from .builtin.refresh_mcp_tools import RefreshMCPToolsTool
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
from .external.mcp_client import MCPClient
from ..debug import debug_log
# Registry of all builtin tools
BUILTIN_TOOLS = {
"screenshot": ScreenshotTool(),
"webSearch": WebSearchTool(),
"localFiles": LocalFilesTool(),
"fetchWebPage": FetchWebPageTool(),
"logMeal": LogMealTool(),
"fetchMeals": FetchMealsTool(),
"deleteMeal": DeleteMealTool(),
"refreshMCPTools": RefreshMCPToolsTool(),
"getWeather": WeatherTool(),
"stop": StopTool(),
"toolSearchTool": ToolSearchTool(),
"browseAndPlay": BrowseAndPlayTool(),
"controlBrowser": ControlBrowserTool(),
"setBroadcast": SetBroadcastTool(),
}
# Global MCP tools cache
_mcp_tools_cache: Dict[str, "ToolSpec"] = {}
_mcp_tools_cache_lock = threading.Lock()
_mcp_config_cache: Dict[str, Any] = {}
def initialize_mcp_tools(mcps_config: Dict[str, Any], verbose: bool = True) -> Tuple[Dict[str, "ToolSpec"], Dict[str, str]]:
"""
Initialize MCP tools cache at startup.
Args:
mcps_config: MCP server configuration
verbose: Whether to print status messages
Returns:
Tuple of (discovered_tools, errors) where errors maps server name to error message.
"""
global _mcp_tools_cache, _mcp_config_cache
with _mcp_tools_cache_lock:
_mcp_config_cache = mcps_config or {}
_mcp_tools_cache, errors = discover_mcp_tools(mcps_config)
if verbose and _mcp_tools_cache:
debug_log(f"MCP tools cache initialized with {len(_mcp_tools_cache)} tools", "mcp")
return _mcp_tools_cache.copy(), errors
def get_cached_mcp_tools() -> Dict[str, "ToolSpec"]:
"""Get cached MCP tools without rediscovering."""
with _mcp_tools_cache_lock:
return _mcp_tools_cache.copy()
def refresh_mcp_tools(verbose: bool = True) -> Tuple[Dict[str, "ToolSpec"], Dict[str, str]]:
"""
Refresh MCP tools cache by rediscovering all tools.
Returns:
Tuple of (discovered_tools, errors) where errors maps server name to error message.
"""
global _mcp_tools_cache
with _mcp_tools_cache_lock:
if not _mcp_config_cache:
debug_log("No MCP config cached, skipping refresh", "mcp")
return {}, {}
if verbose:
print("🔄 Refreshing MCP tools...", flush=True)
_mcp_tools_cache, errors = discover_mcp_tools(_mcp_config_cache)
if verbose:
print(f" ✅ Found {len(_mcp_tools_cache)} MCP tools", flush=True)
debug_log(f"MCP tools cache refreshed with {len(_mcp_tools_cache)} tools", "mcp")
return _mcp_tools_cache.copy(), errors
def is_mcp_cache_initialized() -> bool:
"""Check if MCP tools cache has been initialized."""
with _mcp_tools_cache_lock:
return len(_mcp_config_cache) > 0 or len(_mcp_tools_cache) > 0
# ToolSpec for MCP compatibility
@dataclass(frozen=True)
class ToolSpec:
name: str # canonical tool identifier (camelCase)
description: str # Human-readable description (matches MCP format)
inputSchema: Optional[Dict[str, Any]] = None # JSON Schema for arguments (matches MCP format)
def discover_mcp_tools(mcps_config: Dict[str, Any]) -> Tuple[Dict[str, ToolSpec], Dict[str, str]]:
"""Discover all tools from configured MCP servers and create ToolSpec entries for them.
Returns:
Tuple of (discovered_tools, errors) where errors maps server name to error message.
"""
if not mcps_config:
return {}, {}
try:
client = MCPClient(mcps_config)
discovered_tools = {}
errors: Dict[str, str] = {}
for server_name in mcps_config.keys():
try:
tools = client.list_tools(server_name)
for tool_info in tools:
tool_name = tool_info.get("name")
if not tool_name:
continue
# Create a unique tool name: server__toolname
full_tool_name = f"{server_name}__{tool_name}"
# Create a ToolSpec for this MCP tool
description = tool_info.get("description", f"Tool from {server_name} MCP server")
input_schema = tool_info.get("inputSchema", {"type": "object", "properties": {}, "required": []})
discovered_tools[full_tool_name] = ToolSpec(
name=full_tool_name,
description=description,
inputSchema=input_schema
)
except BaseException as e:
# ExceptionGroups (from anyio TaskGroup) wrap the real cause;
# extract the first sub-exception for a useful error message.
cause = e
if hasattr(e, "exceptions") and e.exceptions:
cause = e.exceptions[0]
debug_log(f"Failed to discover tools from MCP server '{server_name}': {cause}", "mcp")
errors[server_name] = str(cause)
continue
return discovered_tools, errors
except Exception as e:
debug_log(f"Failed to discover MCP tools: {e}", "mcp")
return {}, {"_global": str(e)}
def generate_tools_json_schema(allowed_tools: Optional[List[str]] = None, mcp_tools: Optional[Dict[str, ToolSpec]] = None) -> List[Dict[str, Any]]:
"""
Generate tools in OpenAI-compatible JSON schema format for native tool calling.
This format is supported by Ollama for models with native tool calling support
(Llama 3.1+, Llama 3.2, Qwen 3, Mistral, etc.).
Returns a list of tool definitions in this format:
[
{
"type": "function",
"function": {
"name": "toolName",
"description": "Tool description",
"parameters": {
"type": "object",
"properties": {...},
"required": [...]
}
}
}
]
"""
names = list(allowed_tools or list(BUILTIN_TOOLS.keys()))
tools: List[Dict[str, Any]] = []
# Add built-in tools
for tool_name in names:
tool = BUILTIN_TOOLS.get(tool_name)
if not tool:
continue
tool_def = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema or {"type": "object", "properties": {}, "required": []},
}
}
tools.append(tool_def)
# Add discovered MCP tools
if mcp_tools:
for tool_name, spec in mcp_tools.items():
if tool_name in names: # Only include if allowed
tool_def = {
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": spec.inputSchema or {"type": "object", "properties": {}, "required": []},
}
}
tools.append(tool_def)
return tools
def generate_tools_description(allowed_tools: Optional[List[str]] = None, mcp_tools: Optional[Dict[str, ToolSpec]] = None) -> str:
"""Produce a compact tool help string for the system prompt using OpenAI standard format."""
names = list(allowed_tools or list(BUILTIN_TOOLS.keys()))
lines: List[str] = []
lines.append("Tool-use protocol: Use the tool_calls field in your response:")
lines.append('tool_calls: [{"id": "call_<id>", "type": "function", "function": {"name": "<toolName>", "arguments": "<json_string>"}}]')
lines.append("\nAvailable tools and when to use them:")
# Add built-in tools
for tool_name in names:
tool = BUILTIN_TOOLS.get(tool_name)
if not tool:
continue
lines.append(f"\n{tool.name}: {tool.description}")
if tool.inputSchema:
# Extract a simple parameter summary from the JSON schema
props = tool.inputSchema.get("properties", {})
required = tool.inputSchema.get("required", [])
param_descriptions = []
for prop_name, prop_def in props.items():
prop_type = prop_def.get("type", "any")
is_required = prop_name in required
req_marker = " (required)" if is_required else ""
param_descriptions.append(f"{prop_name}: {prop_type}{req_marker}")
if param_descriptions:
lines.append(f"Input: {', '.join(param_descriptions)}")
# Add discovered MCP tools
if mcp_tools:
for tool_name, spec in mcp_tools.items():
if tool_name in names: # Only include if allowed
lines.append(f"\n{spec.name}: {spec.description}")
if spec.inputSchema:
# Extract a simple parameter summary from the JSON schema
props = spec.inputSchema.get("properties", {})
required = spec.inputSchema.get("required", [])
param_descriptions = []
for prop_name, prop_def in props.items():
prop_type = prop_def.get("type", "any")
is_required = prop_name in required
req_marker = " (required)" if is_required else ""
param_descriptions.append(f"{prop_name}: {prop_type}{req_marker}")
if param_descriptions:
lines.append(f"Input: {', '.join(param_descriptions)}")
return "\n".join(lines)
def _normalize_time_range(args: Optional[Dict[str, Any]]) -> Tuple[str, str]:
now = datetime.now(timezone.utc)
since: Optional[str] = None
until: Optional[str] = None
if args and isinstance(args, dict):
try:
since_val = args.get("since_utc")
since = str(since_val) if since_val else None
except Exception:
since = None
try:
until_val = args.get("until_utc")
until = str(until_val) if until_val else None
except Exception:
until = None
if since is None and until is None:
# Default last 24h
return (now - timedelta(days=1)).isoformat(), now.isoformat()
if since is None and until is not None:
# backfill 24h prior to until
try:
until_dt = datetime.fromisoformat(until.replace("Z", "+00:00"))
except Exception:
until_dt = now
return (until_dt - timedelta(days=1)).isoformat(), until_dt.isoformat()
if since is not None and until is None:
return since, now.isoformat()
return since or (now - timedelta(days=1)).isoformat(), until or now.isoformat()
def run_tool_with_retries(
db,
cfg: Settings,
tool_name: str,
tool_args: Optional[Dict[str, Any]],
system_prompt: str,
original_prompt: str,
redacted_text: str,
max_retries: int = 1,
language: Optional[str] = None,
) -> ToolExecutionResult:
# Normalize tool name to canonical camelCase
raw_name = (tool_name or "").strip()
name = raw_name
# Check if tool name is a discovered MCP tool (server__toolname format)
if "__" in raw_name:
server_name, mcp_tool_name = raw_name.split("__", 1)
mcps_config = getattr(cfg, "mcps", {})
if mcps_config and server_name in mcps_config:
try:
if MCPClient is None:
return ToolExecutionResult(success=False, reply_text=None, error_message="MCP client not available. Install 'mcp' package.")
client = MCPClient(mcps_config)
result = client.invoke_tool(server_name=server_name, tool_name=mcp_tool_name, arguments=tool_args or {})
is_error = bool(result.get("isError", False))
text = result.get("text") or None
return ToolExecutionResult(success=(not is_error), reply_text=text, error_message=(text if is_error else None))
except Exception as e:
return ToolExecutionResult(success=False, reply_text=None, error_message=f"MCP tool '{raw_name}' error: {e}")
# Friendly user print helper (non-debug only)
def _user_print(message: str) -> None:
# 4-space indent: tool messages happen INSIDE an agentic-loop
# turn. The turn header (` 🔁 Turn N/M`) sits at 2 spaces, so
# per-tool activity nests one level deeper for visual hierarchy.
if not getattr(cfg, "voice_debug", False):
try:
print(f" {message}")
except Exception:
pass
# Check builtin tools first
if name in BUILTIN_TOOLS:
tool = BUILTIN_TOOLS[name]
return tool.execute(
db=db,
cfg=cfg,
tool_args=tool_args,
system_prompt=system_prompt,
original_prompt=original_prompt,
redacted_text=redacted_text,
max_retries=max_retries,
user_print=_user_print,
language=language,
)
# Unknown tool
debug_log(f"unknown tool requested: {tool_name}", "tools")
return ToolExecutionResult(success=False, reply_text=None, error_message=f"Unknown tool: {tool_name}")