Terminal / api /speculative.py
Pulka
deploy: backend S766+S-SCHED-SSE b5b4ee2e — 2026-06-13 16:28 UTC
8a262b7 verified
Raw
History Blame
14.3 kB
"""
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