Spaces:
Running
Running
File size: 19,157 Bytes
85a0eea 5ef3828 85a0eea ba12a56 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 45ed3d8 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea 5ef3828 85a0eea ba12a56 5ef3828 ba12a56 5ef3828 ba12a56 5ef3828 85a0eea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 | # =============================================================================
# app/tools.py
# Tool Registry — Modular Wrapper
# Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
# Copyright 2026 - Volkan Kücükbudak
# Apache License V. 2 + ESOL 1.1
# Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed
# =============================================================================
# ARCHITECTURE NOTE:
# This file lives exclusively in app/ and is ONLY started by app/app.py.
# NO direct access to fundaments/*, .env, or Guardian (main.py).
# All config comes from app/.pyfun via app/config.py.
#
# TOOL REGISTRY PRINCIPLE:
# Tools are defined in .pyfun [TOOLS] — never hardcoded here.
# Adding a new tool = update .pyfun only. Never touch this file.
# config.py parses [TOOLS] and delivers ready-to-use dicts.
#
# DEPENDENCY CHAIN:
# .pyfun → config.py → tools.py → mcp.py
# tools.py delegates execution to providers.py — never calls APIs directly.
# =============================================================================
import logging
import os
from pathlib import Path
from typing import Any, Dict, Optional
from . import config # reads app/.pyfun — single source of truth
from . import providers # LLM + Search execution + fallback chain
from . import db_sync
logger = logging.getLogger("tools")
# =============================================================================
# Internal Registry — built from .pyfun [TOOLS] at initialize()
# =============================================================================
_registry: Dict[str, Dict] = {}
# =============================================================================
# Initialization — called by app/app.py (parameterless, sandboxed)
# =============================================================================
def initialize() -> None:
"""
Builds the tool registry from .pyfun [TOOLS].
Called once by app/app.py during startup sequence.
No fundaments passed in — fully sandboxed.
Loads all active tools and their config (description, provider_type,
default_provider, timeout_sec, system_prompt, etc.) into _registry.
Inactive tools (active = "false") are skipped silently.
"""
global _registry
_registry = config.get_active_tools()
logger.info(f"Tools loaded: {list(_registry.keys())}")
for tname, tcfg in _registry.items():
skill = tcfg.get("system_prompt_file", "")
if skill:
try:
_load_skill(skill)
logger.info(f"Tool '{tname}': skill '{skill}' loaded.")
except ValueError as e:
logger.warning(f"Tool '{tname}': {e}")
# =============================================================================
# Health — shared by the MCP tool and the REST gateway. Cheap, never raises, never blocks long.
# =============================================================================
async def health() -> Dict[str, Any]:
"""Hub status incl. PostgreSQL awake-check (short ping), queue depth, providers, integrations."""
from . import pricing, discord_api, gsc
db = await db_sync.pg_status()
try:
queue = (await db_sync.persist_queue_stats()).get("queue_depth")
except Exception:
queue = None
degraded = db.get("bridge") == "active" and db.get("state") != "up"
return {
"status": "degraded" if degraded else "ok",
"service": "Universal MCP Hub",
"database": db,
"persist_queue": queue,
"llm_providers": providers.list_active_llm(),
"search_providers": providers.list_active_search(),
"pricing_ready": pricing.is_ready(),
"stale_models": pricing.check_models(),
"integrations": {"discord": discord_api.is_ready(), "gsc": gsc.is_ready()},
}
# =============================================================================
# Skills — system prompts as plain Markdown files in app/skills/
# .pyfun [TOOL.xyz]: system_prompt_file = "my_skill.md" (combined with system_prompt if both set)
# DATA only: the file is read as text and sent as system prompt. No code is ever executed
# or imported from app/skills/ — dropping a file there cannot run anything.
# =============================================================================
_SKILLS_DIR = Path(__file__).resolve().parent / "skills"
_SKILL_MAX_CHARS = 20000
_skill_cache: Dict[str, str] = {}
def _load_skill(name: str) -> str:
"""Read app/skills/<name> (.md/.txt only, no path tricks). Cached until restart."""
name = (name or "").strip()
if not name:
return ""
if name in _skill_cache:
return _skill_cache[name]
path = (_SKILLS_DIR / name).resolve()
if _SKILLS_DIR not in path.parents or path.suffix.lower() not in (".md", ".txt"):
raise ValueError(f"system_prompt_file '{name}' must be a .md/.txt file inside app/skills/")
try:
text = path.read_text(encoding="utf-8").strip()[:_SKILL_MAX_CHARS]
except OSError:
raise ValueError(f"system_prompt_file '{name}' not found in app/skills/") from None
_skill_cache[name] = text
return text
# =============================================================================
# Discord post (hub → Discord). Inbound (/discord route, /ask) lives in app.py.
# =============================================================================
async def _run_discord(content: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Post `content` to Discord. mode: "auto" (default) | "webhook" | "bot".
A channel_id override is only accepted if listed in [DISCORD] DISCORD_ALLOWED_CHANNELS
(or equals DISCORD_DEFAULT_CHANNEL) — a hub client must not be able to make the bot
write into arbitrary channels.
"""
from . import discord_api
cfg = config.get_discord()
allowed = {c.strip() for c in str(cfg.get("DISCORD_ALLOWED_CHANNELS", "")).split(",") if c.strip()}
if cfg.get("DISCORD_DEFAULT_CHANNEL"):
allowed.add(str(cfg["DISCORD_DEFAULT_CHANNEL"]).strip())
channel = str(params.get("channel_id") or "").strip()
if channel and channel not in allowed:
return {"ok": False, "error": "channel_id not allowed — add it to DISCORD_ALLOWED_CHANNELS in .pyfun"}
mode = str(params.get("mode") or "auto").lower()
if mode == "webhook" or (mode == "auto" and not channel and discord_api.get_status()["webhook_set"]):
r = await discord_api.send_webhook(str(content))
else:
r = await discord_api.send_message(channel, str(content))
return {"ok": bool(r.get("ok")), "error": r.get("error"), "message_id": (r.get("data") or {}).get("id")}
# =============================================================================
# Response cache helper (opt-in per tool, backed by db_sync.tool_cache)
# =============================================================================
async def _cached(tool_name: str, key: str, tool_cfg: Dict, enabled: bool, call) -> str:
"""Run `call()` (async callable); with cache = "true" serve/store via db_sync. Never breaks the call."""
if not enabled:
return await call()
try:
ttl = int(tool_cfg.get("cache_ttl_sec", "3600"))
except (TypeError, ValueError):
ttl = 3600
try:
hit = await db_sync.cache_read(tool_name, key, max_age_sec=ttl)
if hit is not None:
logger.info(f"Tool '{tool_name}': cache hit.")
return hit
except Exception as e:
logger.debug(f"Cache read skipped: {type(e).__name__}")
result = await call()
try:
await db_sync.cache_write(tool_name, key, result)
except Exception as e:
logger.debug(f"Cache write skipped: {type(e).__name__}")
return result
# =============================================================================
# Public API — used by mcp.py tool handlers
# =============================================================================
async def run(
tool_name: str,
prompt: Optional[str] = None,
provider_name: Optional[str] = None,
model: Optional[str] = None,
max_tokens: int = 1024,
max_results: int = 5,
**params: Any,
) -> str:
"""
Execute a tool by name.
Reads tool config from registry, delegates to providers.py / db_sync.py.
Applies system_prompt from .pyfun if defined.
Args:
tool_name: Tool name as defined in .pyfun [TOOLS] (e.g. 'llm_complete').
prompt: User input / query string. For db tools this is the SQL query.
provider_name: Override provider. Defaults to tool's default_provider in .pyfun.
model: Override model. Defaults to provider's default_model in .pyfun.
max_tokens: Max tokens for LLM response. Default: 1024.
max_results: Max results for search tools. Default: 5.
**params: Ignored extras — keeps /api calls with unknown keys from crashing.
Returns:
Tool response as plain text string.
Raises:
ValueError: If tool_name is not found in registry or prompt is missing.
RuntimeError: If all providers fail (propagated from providers.py).
"""
tool_cfg = _registry.get(tool_name)
if not tool_cfg:
raise ValueError(f"Tool '{tool_name}' not found in registry or not active.")
provider_type = tool_cfg.get("provider_type", "llm")
default_provider = provider_name or tool_cfg.get("default_provider", "")
system_prompt = tool_cfg.get("system_prompt", "")
if tool_cfg.get("system_prompt_file"):
system_prompt = f"{system_prompt}\n\n{_load_skill(tool_cfg['system_prompt_file'])}".strip()
# Skill DBs (app/skills_db.py): core rules first, relevant user skills last. Opt-in (costs tokens).
if provider_type == "llm":
from . import skills_db
scfg = config.get_skills()
def _on(tool_key: str, global_key: str) -> bool:
return str(tool_cfg.get(tool_key, scfg.get(global_key, "false"))).lower() == "true"
if skills_db.is_ready():
if _on("use_core", "SKILLS_INJECT_CORE") and skills_db.core_text():
system_prompt = f"{skills_db.core_text()}\n\n{system_prompt}".strip()
if _on("use_skills", "SKILLS_INJECT_RELEVANT"):
ctx = await skills_db.context_for(
str(prompt or ""), int(scfg.get("SKILLS_TOP_K", "3")), int(scfg.get("SKILLS_MAX_CHARS", "3000")))
if ctx:
system_prompt = f"{system_prompt}\n\n{ctx}".strip()
# prompt ist für llm/search/db Pflicht — sauberer 400er statt TypeError/500er
# persist ist die Ausnahme: ohne state_read_key braucht es einen prompt,
# mit state_read_key liest es stattdessen aus hub_state (Gedächtnis-Pattern).
needs_prompt = provider_type in ("llm", "search", "db", "discord") or \
(provider_type == "skills" and tool_cfg.get("skills_action") == "search")
if needs_prompt and (prompt is None or str(prompt).strip() == ""):
raise ValueError(
f"Tool '{tool_name}' requires a 'prompt' parameter (db: the SQL query, discord: the message)."
)
if provider_type == "persist" and not tool_cfg.get("state_read_key", "") \
and (prompt is None or str(prompt).strip() == ""):
raise ValueError(
"persist_result requires 'prompt' (or set state_read_key in .pyfun [TOOL.persist_result])."
)
# --- Skill DBs (read-only): catalog / search ---
if provider_type == "skills":
from . import skills_db
if tool_cfg.get("skills_action") == "catalog":
return await skills_db.catalog(scope=str(params.get("scope") or "all"), tag=str(params.get("tag") or ""))
return await skills_db.search(
query=str(prompt or ""), top_k=params.get("top_k", 3),
tag=str(params.get("tag") or ""), scope=str(params.get("scope") or "user"))
# --- Discord post ---
if provider_type == "discord":
return await _run_discord(prompt, params)
# --- Google Search Console (optional libs, see app/gsc.py) ---
if provider_type == "gsc":
from . import gsc
return await gsc.query(
property_url=str(params.get("property_url") or prompt or ""),
days_back=params.get("days_back", 30),
row_limit=params.get("row_limit", 10),
dimension=params.get("dimension", "query"),
)
# Opt-in response cache per tool: cache = "true" (+ cache_ttl_sec) in .pyfun [TOOL.xyz]
use_cache = str(tool_cfg.get("cache", "false")).lower() == "true"
# --- LLM tools ---
# system_prompt goes out as a REAL system prompt (no longer glued into the user prompt).
# Only a model given (no provider) → provider is derived from .pyfun [MODELS].
if provider_type == "llm":
prov = provider_name or providers.provider_for_model(model) or tool_cfg.get("default_provider", "")
return await _cached(
tool_name, f"{prov}|{model or ''}|{max_tokens}|{system_prompt}|{prompt}", tool_cfg, use_cache,
lambda: providers.llm_complete(
prompt=prompt,
provider_name=prov,
model=model,
max_tokens=max_tokens,
system=system_prompt or None,
tool_name=tool_name,
),
)
# --- Search tools ---
# providers.search() ignores provider names that are not search providers
# (e.g. a GUI sending its LLM provider along).
if provider_type == "search":
n_results = max_results or int(tool_cfg.get("default_results", "5"))
return await _cached(
tool_name, f"{default_provider}|{n_results}|{prompt}", tool_cfg, use_cache,
lambda: providers.search(
query=prompt,
provider_name=default_provider,
max_results=n_results,
),
)
# --- DB tools (read-only, delegated to db_sync when ready) ---
if provider_type == "db":
sql = prompt # prompt ist hier die SQL-Query
return await db_sync.query(sql)
# --- Persist tools (Gedächtnis-Orchestrierung: SQLite-Queue → PostgreSQL) ---
# NIEMALS direkt an PG schreiben — enqueue in die restart-safe Queue,
# dann EINEN best-effort Flush-Versuch. Ohne DATABASE_URL = status "queued",
# kein Crash. Der Background-Flusher (db_sync) retries später automatisch.
if provider_type == "persist":
key = tool_cfg.get("state_read_key", "")
table = tool_cfg.get("target_table", "hub_results")
source = str(params.get("source") or tool_name)
# score: optionaler Slot für den Bullshit-Filter (Nutzer-eigenes Scoring).
# Wird 1:1 durchgereicht — KEIN Tool hier interpretiert oder berechnet ihn.
try:
score = float(params["score"]) if params.get("score") is not None else None
except (TypeError, ValueError):
score = None
data = await db_sync.read(key) if key else {"prompt": prompt}
depth = await db_sync.enqueue_persist(
table, payload=data, source=source, model=model or "", score=score,
)
# best effort — waits at most PG_REQUEST_WAIT_SEC; a sleeping Neon never blocks the request
flush = await db_sync.flush_persist_queue_bounded()
if flush.get("flushed", 0) > 0:
status, note = "persisted", "written to PostgreSQL"
else:
status = "queued"
note = {
"pg_slow": "PostgreSQL is slow/waking up — stays queued (SQLite) and flushes in the background",
"pg_backoff": "PostgreSQL unreachable a moment ago — stays queued (SQLite), retried automatically",
}.get(flush.get("reason"), "restart-safe in SQLite — flushes to PostgreSQL when the bridge becomes active")
return {
"status": status,
"target_table": table,
"queue_depth": depth,
"flushed": flush.get("flushed", 0),
"note": note,
}
# --- Unknown provider type ---
logger.warning(f"Tool '{tool_name}' has unknown provider_type '{provider_type}' — skipped.")
return f"Tool '{tool_name}' provider type '{provider_type}' is not yet implemented."
# =============================================================================
# Registry helpers — used by mcp.py and system tools
# =============================================================================
def get(tool_name: str) -> Dict[str, Any]:
"""
Get full config dict for a tool.
Args:
tool_name: Tool name as defined in .pyfun [TOOLS].
Returns:
Tool config dict, or empty dict if not found.
"""
return _registry.get(tool_name, {})
def get_description(tool_name: str) -> str:
"""
Get the description of a tool (from .pyfun).
Args:
tool_name: Tool name as defined in .pyfun [TOOLS].
Returns:
Description string, or empty string if not found.
"""
return _registry.get(tool_name, {}).get("description", "")
def get_system_prompt(tool_name: str) -> str:
"""
Get the system_prompt of a tool (from .pyfun).
Returns empty string if no system_prompt is defined.
Args:
tool_name: Tool name as defined in .pyfun [TOOLS].
Returns:
System prompt string, or empty string if not configured.
"""
return _registry.get(tool_name, {}).get("system_prompt", "")
def get_timeout(tool_name: str) -> int:
"""
Get the timeout in seconds for a tool (from .pyfun).
Args:
tool_name: Tool name as defined in .pyfun [TOOLS].
Returns:
Timeout in seconds (int). Defaults to 60 if not configured.
"""
return int(_registry.get(tool_name, {}).get("timeout_sec", "60"))
def get_provider_type(tool_name: str) -> str:
"""
Get the provider_type of a tool (llm | search | db | image | sandbox).
Args:
tool_name: Tool name as defined in .pyfun [TOOLS].
Returns:
Provider type string, or empty string if not found.
"""
return _registry.get(tool_name, {}).get("provider_type", "")
def list_all() -> list:
"""
List all active tool names from registry.
Returns:
List of active tool name strings.
"""
return list(_registry.keys())
def list_by_type(provider_type: str) -> list:
"""
List all active tools of a specific provider_type.
Args:
provider_type: e.g. 'llm', 'search', 'db', 'image', 'sandbox'.
Returns:
List of tool name strings matching the provider_type.
"""
return [
name for name, cfg in _registry.items()
if cfg.get("provider_type", "") == provider_type
]
# =============================================================================
# Direct execution guard
# =============================================================================
if __name__ == "__main__":
print("WARNING: Run via main.py → app.py, not directly.")
|