Spaces:
Sleeping
Sleeping
| /** | |
| * RouteSelector.ts — Adaptive A/B routing between GraphOrchestrator and ReAct (FASE 5) | |
| * | |
| * Selects the best execution path based on: | |
| * 1. Deterministic rules (low complexity → react, high+3tools → graph) | |
| * 2. Historical quality scores per task signature (stored in agentMemory) | |
| * 3. 10% epsilon-greedy exploration to avoid local optima | |
| * | |
| * S344: RouteStats ora traccia qualityScore (0–1 da goalVerifier) invece di binario | |
| * success/failure — il RouteSelector sceglie per qualità reale, non lunghezza testo. | |
| * | |
| * 100% local, no API calls, synchronous selection, async persistence. | |
| */ | |
| import { agentMemory } from "../agentMemory"; | |
| import type { PlanLike } from "./GraphOrchestrator"; | |
| export type Route = "graph" | "react"; | |
| export interface RouteOutcome { | |
| route: Route; | |
| success: boolean; | |
| latencyMs: number; | |
| qualityScore?: number; // S344: 0–1 da goalVerifier (sostituisce heuristica lunghezza) | |
| } | |
| interface RouteStats { | |
| gs: number; // graph successes (binario — backward compat) | |
| gt: number; // graph total | |
| rs: number; // react successes (binario — backward compat) | |
| rt: number; // react total | |
| gqs: number; // graph quality score sum (S344 — goalVerifier scores) | |
| rqs: number; // react quality score sum (S344 — goalVerifier scores) | |
| } | |
| const STORAGE_KEY = "route_selector:v2"; // S344: bump version — nuovo schema stats | |
| const MIN_SAMPLES_FOR_STATS = 2; // FIX-5a: 2 sample già sufficienti su progetti piccoli | |
| const EXPLORE_RATE = 0.12; // FIX-5b: 12% — più esplorazione con pochi task totali | |
| function planSignature(plan: PlanLike): string { | |
| const tools = [...new Set(plan.subtasks.filter(s => s.tool !== "respond").map(s => s.tool))].sort().join(","); | |
| return `${plan.complexity}|${tools}`; | |
| } | |
| /** | |
| * S344: usa qualityScore medio se disponibile (goalVerifier), altrimenti successRate binario. | |
| * qualitySum > 0 → almeno una sessione con goalVerifier attivo → score medio più accurato. | |
| */ | |
| function winRate(success: number, total: number, qualitySum: number): number { | |
| if (total === 0) return 0.5; // prior ottimistico al primo run | |
| if (qualitySum > 0) return qualitySum / total; // goalVerifier score — il più accurato | |
| // FIX-5c: Laplace smoothing — (success+1)/(total+2) evita 0.0/1.0 con 1-2 sample | |
| return (success + 1) / (total + 2); | |
| } | |
| export class RouteSelector { | |
| private stats = new Map<string, RouteStats>(); | |
| private loaded = false; | |
| // Lazy-load from agentMemory on first use | |
| async ensureLoaded(): Promise<void> { | |
| if (this.loaded) return; | |
| this.loaded = true; | |
| try { | |
| // Tenta prima la chiave v2 (S344), poi v1 per backward compat | |
| const raw = agentMemory.get(STORAGE_KEY) ?? agentMemory.get("route_selector:v1"); | |
| if (raw) { | |
| const obj = JSON.parse(raw) as Record<string, RouteStats>; | |
| for (const [k, v] of Object.entries(obj)) { | |
| // S344: backward compat — old v1 entries non hanno gqs/rqs | |
| this.stats.set(k, { ...v, gqs: v.gqs ?? 0, rqs: v.rqs ?? 0 }); | |
| } | |
| } | |
| } catch { /* first run — empty stats */ } | |
| } | |
| private persist(): void { | |
| try { | |
| const obj: Record<string, RouteStats> = {}; | |
| for (const [k, v] of this.stats.entries()) { | |
| obj[k] = v; | |
| } | |
| agentMemory.set(STORAGE_KEY, JSON.stringify(obj), "routing"); | |
| } catch { /* non-blocking */ } | |
| } | |
| /** Select the route for a given plan. Must call ensureLoaded() first. */ | |
| select(plan: PlanLike): Route { | |
| const toolCount = plan.subtasks.filter(s => s.tool !== "respond").length; | |
| // ── Hard rules (deterministic) ────────────────────────────────────────── | |
| if (plan.complexity === "low" || toolCount <= 1) return "react"; | |
| if (plan.complexity === "high" && toolCount >= 3) return "graph"; | |
| // ── Epsilon-greedy exploration ─────────────────────────────────────────── | |
| if (Math.random() < EXPLORE_RATE) { | |
| return Math.random() < 0.5 ? "graph" : "react"; | |
| } | |
| // ── Stats-based selection (S344: qualità-pesata) ───────────────────────── | |
| const sig = planSignature(plan); | |
| const s = this.stats.get(sig); | |
| if (!s || (s.gt + s.rt) < MIN_SAMPLES_FOR_STATS) { | |
| // Default for medium complexity: graph if 2+ tools, react otherwise | |
| return toolCount >= 2 ? "graph" : "react"; | |
| } | |
| const graphWin = winRate(s.gs, s.gt, s.gqs); | |
| const reactWin = winRate(s.rs, s.rt, s.rqs); | |
| return graphWin >= reactWin ? "graph" : "react"; | |
| } | |
| /** | |
| * Record the outcome of a completed execution for future routing decisions. | |
| * S344: qualityScore (da goalVerifier) sostituisce l'heuristica binaria length > 80. | |
| * Fallback: se qualityScore non disponibile (task senza formalGoal), usa 0.8/0.2. | |
| */ | |
| record(plan: PlanLike, outcome: RouteOutcome): void { | |
| const sig = planSignature(plan); | |
| const s = this.stats.get(sig) ?? { gs: 0, gt: 0, rs: 0, rt: 0, gqs: 0, rqs: 0 }; | |
| // S344: quality score da usare — goalVerifier se disponibile, fallback binario | |
| const quality = outcome.qualityScore ?? (outcome.success ? 0.8 : 0.2); | |
| if (outcome.route === "graph") { | |
| s.gt++; | |
| if (outcome.success) s.gs++; | |
| s.gqs += quality; | |
| } else { | |
| s.rt++; | |
| if (outcome.success) s.rs++; | |
| s.rqs += quality; | |
| } | |
| this.stats.set(sig, s); | |
| this.persist(); | |
| } | |
| /** Debug: return stats for all known signatures */ | |
| dump(): Record<string, RouteStats> { | |
| const out: Record<string, RouteStats> = {}; | |
| for (const [k, v] of this.stats.entries()) out[k] = v; | |
| return out; | |
| } | |
| } | |
| export const routeSelector = new RouteSelector(); | |