391 lines
12 KiB
JavaScript
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')
|
|
}
|