""" backend/api/speculative.py — Speculative Tool Firing (S361) Pre-fires tool calls in parallel while the main model is processing. Uses Groq llama-3.1-8b-instant for ultra-fast intent extraction (~200-300ms). Results stored in a per-goal cache, consumed by _run_direct_tools before actual execution. Architecture: 1. Task created → fire_speculative_tools(task_id, goal) [fire-and-forget] 2. Groq 8b extracts likely tools (JSON) in ~300ms 3. Tools fired in parallel via TOOL_REGISTRY 4. Results buffered in _spec_cache[goal_hash] 5. _run_direct_tools checks cache via get_speculative_result() → cache hit = 0ms latency Invariants: - NEVER blocks task creation or streaming response - NEVER raises — all errors are silent fallbacks - Cache TTL: 90s (expires before any reasonable task completes) - Only read-only, side-effect-free tools are speculated """ from __future__ import annotations import asyncio import hashlib import json import os import re import time from typing import Any # ── Speculative cache ───────────────────────────────────────────────────────── # Keyed by goal_hash → {tool_key: result_str, _expires_at: float} _spec_cache: dict[str, dict[str, Any]] = {} _CACHE_TTL = 90.0 # seconds _MAX_CACHE = 50 # max concurrent entries (prevents unbounded growth) # Only safe, read-only tools are eligible for speculation. _SPECULATABLE: frozenset[str] = frozenset({ "web_search", "get_weather", "get_news", "search_wikipedia", "get_currency", "fetch_url", "search_github", "get_location", "math_eval", # S-ORCH-8GAP FIX-GAP8: deep speculation — VFS read-only tools "read_file", "directory_tree", "file_search", # Added: git context + listing (added to _KEYWORD_SPEC_MAP, must be speculatable) "git_status", "git_diff", "list_files", }) _EXTRACTION_PROMPT = ( "Analyze this message and respond ONLY with a JSON array of tools to execute.\n" "Available tools:\n" " web_search(query) — search the web for information\n" " get_weather(city) — weather forecast for a city\n" " get_news(query) — latest news articles on a topic\n" " search_wikipedia(query)— search Wikipedia for a topic\n" " get_currency(from,to) — currency/crypto exchange rate\n" " fetch_url(url) — fetch content from a URL\n" " search_github(query) — search GitHub repositories\n" " get_location(query) — geo lookup for a place\n" " math_eval(expression) — evaluate a math expression\n" " read_file(path) — read a file from the virtual filesystem\n" " directory_tree(path) — list directory structure\n" " file_search(query) — search for files by name or content\n" " git_status() — show current git status\n" " git_diff() — show recent git changes\n" " list_files(path) — list files in a directory\n" "Response format: [{\"tool\": \"name\", \"args\": {\"param\": \"value\"}}]\n" "If no tool is needed, respond: []\n" "Rules: be conservative — only suggest tools with >70% probability of being useful.\n\n" "Message: {message}" ) # ── Cache helpers ───────────────────────────────────────────────────────────── def _goal_hash(goal: str) -> str: return hashlib.md5(goal[:300].encode(), usedforsecurity=False).hexdigest()[:12] def _tool_key(tool_name: str, args: dict) -> str: try: s = json.dumps(args, sort_keys=True, ensure_ascii=False)[:400] # S608: 200→400 except Exception: s = str(args)[:400] # S608: 200→400 return f"{tool_name}::{s}" def get_speculative_result(goal: str, tool_name: str, args: dict) -> str | None: """ Legge un risultato speculativo dalla cache. Ritorna None su cache miss o entry scaduta. """ gh = _goal_hash(goal) bucket = _spec_cache.get(gh) if not bucket: return None if bucket.get("_expires_at", 0.0) < time.monotonic(): _spec_cache.pop(gh, None) return None key = _tool_key(tool_name, args) return bucket.get(key) # type: ignore[return-value] def purge_speculative(goal: str) -> None: """Rimuove la cache per un goal (chiamato al termine del task).""" _spec_cache.pop(_goal_hash(goal), None) def _prune_cache() -> None: now = time.monotonic() expired = [k for k, v in _spec_cache.items() if v.get("_expires_at", 0.0) < now] for k in expired: _spec_cache.pop(k, None) # Cap hard: rimuovi le entry più vecchie se sopra il limite if len(_spec_cache) > _MAX_CACHE: oldest = sorted(_spec_cache.keys(), key=lambda k: _spec_cache[k].get("_expires_at", 0.0)) for k in oldest[:len(_spec_cache) - _MAX_CACHE]: _spec_cache.pop(k, None) # ── Tool extraction (Groq 8b fast path) ────────────────────────────────────── # S388: Groq client singleton per speculative — evita new OpenAI() per ogni task. _spec_groq_client: Any = None def _get_spec_groq_client() -> Any: global _spec_groq_client if _spec_groq_client is not None: return _spec_groq_client groq_key = os.getenv("GROQ_API_KEY") if not groq_key: return None try: from openai import OpenAI _spec_groq_client = OpenAI( api_key=groq_key, base_url="https://api.groq.com/openai/v1", timeout=3.0, max_retries=0, ) except Exception: _spec_groq_client = None return _spec_groq_client async def _extract_tools_fast(goal: str) -> list[dict]: """ Usa Groq llama-3.1-8b-instant per estrarre tool calls in ~300ms. Fallback silenzioso → [] se timeout, errore o key assente. """ if not os.getenv("GROQ_API_KEY"): return [] try: client = _get_spec_groq_client() if not client: return [] prompt = _EXTRACTION_PROMPT.format(message=goal[:500]) resp = await asyncio.wait_for( asyncio.to_thread( client.chat.completions.create, model="llama-3.1-8b-instant", messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok ), timeout=3.0, ) raw = (resp.choices[0].message.content or "").strip() # Estrai JSON array anche se ci sono prefissi di testo start = raw.find("[") end = raw.rfind("]") + 1 if start == -1 or end == 0: return [] parsed = json.loads(raw[start:end]) if not isinstance(parsed, list): return [] return [ t for t in parsed if isinstance(t, dict) and isinstance(t.get("tool"), str) and t["tool"] in _SPECULATABLE and isinstance(t.get("args", {}), dict) ] except Exception: return [] # Fallback silenzioso totale # ── Parallel tool executor ──────────────────────────────────────────────────── async def _run_tool_spec(tool_name: str, args: dict) -> tuple[str, str]: """ Esegue un tool speculabile via TOOL_REGISTRY. Ritorna (cache_key, result_str) — result vuoto su errore. """ key = _tool_key(tool_name, args) try: from tools.registry import TOOL_REGISTRY spec = TOOL_REGISTRY.get(tool_name) if not spec: return (key, "") fn = spec.get("_fn") or spec if callable(spec) else None if not fn: return (key, "") if asyncio.iscoroutinefunction(fn): result = await asyncio.wait_for(fn(**args), timeout=8.0) else: result = await asyncio.wait_for(asyncio.to_thread(fn, **args), timeout=8.0) return (key, str(result) if result is not None else "") except Exception: return (key, "") # ── S-ORCH-8GAP FIX-GAP8: Deep File Speculation ───────────────────────────── # Estrae path file espliciti menzionati nel goal (es. "correggi auth.py") _FILE_PATH_RE = re.compile( r'\b([\w./\-]+\.(?:py|ts|tsx|js|jsx|css|html|json|ya?ml|sh|toml|sql|vue|svelte))\b', re.IGNORECASE, ) # Keyword → tool da pre-eseguire per ridurre latenza planner _KEYWORD_SPEC_MAP: list[tuple] = [ # Auth/security → directory_tree (need file structure context) (re.compile(r'\b(login|auth|logout|session|token|jwt|oauth|password|signup|register|SSO|2fa|mfa)\b', re.I), {"tool": "directory_tree", "args": {"path": "."}}), # DB/ORM → directory_tree (re.compile(r'\b(database|schema|migration|model|orm|sql|db|postgres|mysql|sqlite|mongo|redis|supabase|prisma|drizzle)\b', re.I), {"tool": "directory_tree", "args": {"path": "."}}), # Bug/error → directory_tree + git_status (need recent changes context) (re.compile(r'\b(bug|fix|error|crash|exception|traceback|TypeError|ImportError|NameError|SyntaxError|broken|failing|not working)\b', re.I), {"tool": "git_status", "args": {}}), # API/routing → directory_tree (re.compile(r'\b(api|endpoint|route|handler|controller|middleware|fastapi|express|flask|django|router|webhook)\b', re.I), {"tool": "directory_tree", "args": {"path": "."}}), # Frontend/UI → directory_tree (need src structure) (re.compile(r'\b(frontend|component|react|vue|svelte|nextjs|vite|tailwind|css|styling|ui|design|layout|navbar|modal|button)\b', re.I), {"tool": "directory_tree", "args": {"path": "."}}), # DevOps/deploy → git_status (need current state) (re.compile(r'\b(deploy|deployment|ci|cd|github.?actions|dockerfile|docker.?compose|kubernetes|nginx|vercel|netlify|cloudflare)\b', re.I), {"tool": "git_status", "args": {}}), # Testing → directory_tree (re.compile(r'\b(test|testing|spec|jest|pytest|vitest|coverage|unit.?test|integration.?test|e2e)\b', re.I), {"tool": "directory_tree", "args": {"path": "."}}), # Package/deps → read_file package.json or requirements.txt (re.compile(r'\b(package|dependency|dependencies|install|npm|pip|pnpm|yarn|requirements|pyproject|setup.py)\b', re.I), {"tool": "read_file", "args": {"path": "package.json"}}), # Config/env → list_files (re.compile(r'\b(config|configuration|env|environment|secret|variable|\.env|settings|dotenv)\b', re.I), {"tool": "list_files", "args": {"path": "."}}), # Performance → git_diff (what changed recently?) (re.compile(r'\b(performance|slow|latency|optimize|speed|memory|cpu|profil|benchmark|bottleneck)\b', re.I), {"tool": "git_diff", "args": {}}), ] async def _deep_speculate_files(goal: str, bucket: dict) -> None: """ S-ORCH-8GAP FIX-GAP8: Pre-carica file VFS basandosi sul contenuto del goal. Step 1: estrai path file espliciti dal testo → read_file pre-caricato in cache Step 2: per keyword ad alto impatto → directory_tree (mappa struttura progetto) Risultati aggiunti al bucket → disponibili via get_speculative_result() prima che il planner/executor completino il routing. Riduce latenza percepita 2-5s. Fallback silenzioso totale — non blocca mai. """ calls: list[dict] = [] # Step 1: path file espliciti nel goal for m in _FILE_PATH_RE.finditer(goal[:500]): path = m.group(1) key = _tool_key("read_file", {"path": path}) if key not in bucket: calls.append({"tool": "read_file", "args": {"path": path}}) # Step 2: keyword → directory_tree (max una sola per goal) _dir_key = _tool_key("directory_tree", {"path": "."}) if _dir_key not in bucket: for pat, call in _KEYWORD_SPEC_MAP: if pat.search(goal[:400]): calls.append(call) break if not calls: return results = await asyncio.gather( *[_run_tool_spec(c["tool"], c.get("args", {})) for c in calls], return_exceptions=True, ) for res in results: if isinstance(res, tuple) and res[1]: bucket[res[0]] = res[1] # ── Entry point ─────────────────────────────────────────────────────────────── async def fire_speculative_tools(task_id: str, goal: str) -> None: """ Chiamato fire-and-forget da create_agent_task(). Non blocca mai, non lancia mai eccezioni. Fasi: 1. Prune cache scaduta 2. Estrai tool calls tramite Groq 8b (~300ms) 3. Esegui tutti i tool in parallelo (max 8s each) 4. Salva risultati nel buffer keyed by goal_hash """ try: _prune_cache() # Skip se cache già calda per questo goal gh = _goal_hash(goal) if gh in _spec_cache: return # Fase 1: estrazione veloce tool_calls = await _extract_tools_fast(goal) if not tool_calls: return # Fase 2: esecuzione parallela results = await asyncio.gather( *[_run_tool_spec(tc["tool"], tc.get("args", {})) for tc in tool_calls], return_exceptions=True, ) # Fase 3: scrivi in cache bucket: dict[str, Any] = {"_expires_at": time.monotonic() + _CACHE_TTL} for res in results: if isinstance(res, tuple) and res[1]: bucket[res[0]] = res[1] # S-ORCH-8GAP FIX-GAP8: deep speculation file VFS (after external tools) await _deep_speculate_files(goal, bucket) # Salva solo se almeno un tool ha restituito dati if len(bucket) > 1: # > 1 perché _expires_at è sempre presente _spec_cache[gh] = bucket except Exception: pass # Fallback silenzioso assoluto — non impatta mai il task