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>
376 lines
14 KiB
Python
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}")
|
|
|
|
|