Files
peardata/ui/qvac/agents.js
T
Raven Scott e3d0f70b29
Release rolling / release (push) Canceled after 3m10s
Agent UI
2026-07-30 17:32:59 -04:00

391 lines
12 KiB
JavaScript

/**
* 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<any> }) => Promise<any>,
* 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<any> },
* 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')
}