/** * Multi-agent investigation fan-out for QVAC. * * Each "sub-agent" is a specialist tool worker (not a second model load). * They run in parallel, report progress, and produce a compact briefing the * primary model (or tools-only fallback) synthesizes into one answer. */ /** * @typedef {{ * id: string, * label: string, * description: string, * activity?: string, * match?: (q: string) => boolean, * run: (tools: { run: (name: string, args?: object) => Promise }) => Promise, * summarize: (result: any) => string, * }} AgentSpec */ /** @type {AgentSpec[]} */ export const AGENT_SPECS = [ { id: 'host', label: 'Host investigator', description: 'KPIs, findings, health pack', activity: 'Calling investigate_host…', match: (q) => !q || /health|wrong|diagnos|investigat|summar|status|load|cpu|ram|memory|host|alert|anomal/.test( q ), run: (tools) => tools.run('investigate_host', { processLimit: 10, hotLimit: 10 }), summarize: (r) => { if (r?.error) return `error: ${r.error}` const s = r?.summary || {} const findings = (r?.findings || []) .slice(0, 6) .map((f) => `[${f.severity}] ${f.area}: ${f.message}`) .join('; ') return `host=${r?.hostname || '?'} severity=${s.topSeverity || 'ok'} findings=${s.findingCount ?? 0}${findings ? ` · ${findings}` : ''}` }, }, { id: 'hot', label: 'Hot metrics', description: 'Spiking / changing charts', activity: 'Scanning hot_metrics window…', match: (q) => !q || /hot|spik|unusual|metric|chart|cpu|disk|io|net|redis|docker|postgres|nginx/.test(q), run: (tools) => tools.run('hot_metrics', { limit: 12, window: 120 }), summarize: (r) => { if (r?.error) return `error: ${r.error}` const rows = (r?.results || []) .slice(0, 6) .map((h) => `${h.chart}(${h.score}) ${h.reason || ''}`) return rows.length ? rows.join('; ') : 'no hot charts' }, }, { id: 'processes', label: 'Process scout', description: 'Top CPU / RSS processes', activity: 'Listing top processes by CPU…', match: (q) => !q || /process|top|cpu|load|rss|memory|pid|runaway|hung/.test(q), run: (tools) => tools.run('list_processes', { sort: 'cpu', limit: 12, filter: 'all' }), summarize: (r) => { if (r?.error) return `error: ${r.error}` if (r?.supported === false) return 'processes not enabled on agent' const rows = (r?.processes || r?.top || []) .slice(0, 6) .map((p) => `${p.name || p.comm || '?'} cpu=${p.cpu ?? '—'} rss=${p.rss ?? '—'}`) return rows.length ? rows.join('; ') : 'no process rows' }, }, { id: 'anomalies', label: 'Anomaly / alerts', description: 'Anomalies + open alerts', activity: 'Fetching anomalies & alerts…', match: (q) => !q || /anomal|alert|warn|crit|threshold|silence|page/.test(q), run: async (tools) => { const [anoms, alerts] = await Promise.all([ tools.run('list_anomalies', { limit: 15 }), tools.run('list_alerts', {}), ]) return { anomalies: anoms, alerts } }, summarize: (r) => { const a = r?.anomalies?.anomalies || r?.anomalies || [] const al = r?.alerts?.alerts || r?.alerts || [] const aN = Array.isArray(a) ? a.length : 0 const alN = Array.isArray(al) ? al.length : 0 const head = (Array.isArray(a) ? a : []) .slice(0, 4) .map((x) => `${x.severity || '?'} ${x.chart || ''}: ${x.message || ''}`) .join('; ') return `anomalies=${aN} alerts=${alN}${head ? ` · ${head}` : ''}` }, }, { id: 'fleet', label: 'Fleet scout', description: 'Parent / child fleet health', activity: 'Querying fleet_health…', match: (q) => /fleet|child|peer|parent|cluster|all hosts/.test(q), run: async (tools) => { const [health, children] = await Promise.all([ tools.run('fleet_health', {}), tools.run('list_child_peers', {}), ]) return { health, children } }, summarize: (r) => { const h = r?.health || {} const ch = r?.children?.children || r?.children || [] return `enabled=${h.enabled} children=${Array.isArray(ch) ? ch.length : 0} local=${h.local?.status || '—'}` }, }, { id: 'storage', label: 'Storage / retention', description: 'Disk usage & retention config', activity: 'Reading storage_info…', match: (q) => /storage|retention|prune|disk|warm|history|size|hyperdb/.test(q), run: (tools) => tools.run('storage_info', {}), summarize: (r) => { if (r?.error) return `error: ${r.error}` try { return JSON.stringify(r).slice(0, 280) } catch { return 'storage ok' } }, }, ] /** * Dynamic multi-agent gate — **single-agent by default**. * Only fan out when the user clearly wants a broad investigation (or multi-domain). * * @param {string} userText * @param {{ subAgents?: boolean, subAgentMode?: 'auto'|'always'|'never' }} prefs * @returns {boolean} */ export function shouldUseSubAgents(userText, prefs = {}) { if (prefs.subAgents === false || prefs.subAgentMode === 'never') return false if (prefs.subAgentMode === 'always' && prefs.subAgents !== false) { const q0 = String(userText || '').toLowerCase() // Still skip pure how-to / empty if (!q0.trim()) return false if (/how (do|to)|what is qvac|keyboard|time preset/.test(q0)) return false return true } const q = String(userText || '').toLowerCase().trim() if (!q || q.length < 10) return false // Product / how-to / chart-only / single tool asks → always single if ( /how (do|to)|what is qvac|keyboard|time preset|retention work/.test(q) ) { return false } // UI chart/dashboard building without investigation language if ( /\b(show|plot|graph|open|pin|filter|dashboard)\b/.test(q) && !/\b(investigat|diagnos|what'?s wrong|root cause|health check|full (scan|check))\b/.test(q) ) { return false } // Explicit multi-agent / deep investigation if ( /\b(multi[- ]?agent|investigat|investigation|diagnos(e|is|tic)?|what'?s wrong|whats wrong|what is wrong|root cause|deep dive|full (health|check|scan)|run (all|every) agent|spin up agent)\b/.test( q ) ) { return true } // Broad host health summaries if ( /\b(summarize host|host health|health (check|status|summary)|overall status|everything (wrong|ok)|whole (host|system))\b/.test( q ) ) { return true } // Multi-domain operational question (3+ areas) → worth parallel specialists const domains = [ /\b(cpu|load)\b/, /\b(ram|memory)\b/, /\b(disk|io|storage|iops)\b/, /\b(alert|anomal)/, /\b(process|pid|top)\b/, /\b(fleet|child peer)/, /\b(net|network)\b/, ] const hits = domains.reduce((n, re) => n + (re.test(q) ? 1 : 0), 0) return hits >= 3 } /** * Pick agent specs for this query (capped). * @param {string} userText * @param {number} maxAgents * @returns {AgentSpec[]} */ export function pickAgents(userText, maxAgents = 3) { const q = String(userText || '').toLowerCase() const max = Math.min(6, Math.max(1, Number(maxAgents) || 3)) /** @type {AgentSpec[]} */ const picked = [] for (const spec of AGENT_SPECS) { if (spec.match && !spec.match(q)) continue picked.push(spec) if (picked.length >= max) break } // Always ensure host agent if empty if (!picked.length) picked.push(AGENT_SPECS[0]) // Prefer host first when investigating picked.sort((a, b) => (a.id === 'host' ? -1 : b.id === 'host' ? 1 : 0)) return picked.slice(0, max) } /** * Run specialist agents in parallel. * @param {{ * tools: { run: (name: string, args?: object) => Promise }, * query: string, * maxAgents?: number, * isAborted?: () => boolean, * onPlan?: (agents: AgentSpec[]) => void, * onAgent?: (ev: { * id: string, * label: string, * description?: string, * status: 'queued'|'start'|'running'|'done'|'error'|'stopped', * activity?: string, * summary?: string, * error?: string, * }) => void, * }} opts */ export async function runSubAgents(opts) { const specs = pickAgents(opts.query, opts.maxAgents ?? 3) const tools = opts.tools const aborted = () => Boolean(opts.isAborted?.()) opts.onPlan?.(specs) /** @type {Array<{ id: string, label: string, status: string, summary: string, result?: any, error?: string }>} */ const agents = [] await Promise.all( specs.map(async (spec) => { if (aborted()) { agents.push({ id: spec.id, label: spec.label, status: 'stopped', summary: 'stopped', error: 'stopped', }) opts.onAgent?.({ id: spec.id, label: spec.label, description: spec.description, status: 'stopped', activity: 'Stopped', error: 'stopped', }) return } opts.onAgent?.({ id: spec.id, label: spec.label, description: spec.description, status: 'start', activity: spec.activity || `Running ${spec.label}…`, }) try { const result = await spec.run(tools) if (aborted()) { agents.push({ id: spec.id, label: spec.label, status: 'stopped', summary: 'stopped', error: 'stopped', }) opts.onAgent?.({ id: spec.id, label: spec.label, description: spec.description, status: 'stopped', activity: 'Stopped', error: 'stopped', }) return } const summary = spec.summarize(result) const row = { id: spec.id, label: spec.label, status: result?.error ? 'error' : 'done', summary, result, error: result?.error ? String(result.error) : undefined, } agents.push(row) opts.onAgent?.({ id: spec.id, label: spec.label, description: spec.description, status: row.status === 'error' ? 'error' : 'done', activity: row.status === 'error' ? 'Failed' : 'Complete', summary, error: row.error, }) } catch (err) { const msg = err?.message || String(err) agents.push({ id: spec.id, label: spec.label, status: 'error', summary: msg, error: msg, }) opts.onAgent?.({ id: spec.id, label: spec.label, description: spec.description, status: 'error', activity: 'Failed', error: msg, }) } }) ) // Stable order by AGENT_SPECS const order = new Map(AGENT_SPECS.map((s, i) => [s.id, i])) agents.sort((a, b) => (order.get(a.id) ?? 99) - (order.get(b.id) ?? 99)) const contextText = formatAgentBriefing(agents) return { agents, contextText, specs } } /** * @param {Array<{ id: string, label: string, status: string, summary: string }>} agents */ export function formatAgentBriefing(agents) { if (!agents?.length) return '' const lines = [ '## Multi-agent investigation briefing', 'Specialist agents already collected live data. Use these facts; call extra tools only if a gap remains.', '', ] for (const a of agents) { lines.push(`### ${a.label} (${a.id}) — ${a.status}`) lines.push(a.summary || '(no summary)') lines.push('') } return lines.join('\n').slice(0, 6000) } /** * Tools-only synthesis when no LLM is loaded. * @param {string} userText * @param {{ agents: Array<{ label: string, summary: string, status: string }>, contextText: string }} pack */ export function synthesizeFromAgents(userText, pack) { const lines = [ `**Multi-agent investigation** (${pack.agents.length} specialists)`, '', ] for (const a of pack.agents) { lines.push(`- **${a.label}**: ${a.summary || a.status}`) } lines.push('') lines.push(`_Question: ${userText}_`) lines.push('_Mode: multi-agent tools pack (enable a loaded model for narrative synthesis)._') return lines.join('\n') }