Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
This commit is contained in:
369
src/jarvis/tools/registry.py
Normal file
369
src/jarvis/tools/registry.py
Normal file
@@ -0,0 +1,369 @@
|
||||
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 .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(),
|
||||
}
|
||||
|
||||
# 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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user