Multi Agent Investigations

This commit is contained in:
Raven Scott
2026-07-30 16:22:21 -04:00
parent 0f86534805
commit bcfec67368
8 changed files with 783 additions and 19 deletions
+292
View File
@@ -0,0 +1,292 @@
/**
* 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,
* 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, top processes pack',
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: 'Charts with strongest recent change',
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 processes',
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: 'Recent anomalies and open 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',
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: 'Agent disk and retention config',
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'
}
},
},
]
/**
* Whether this turn should fan out sub-agents.
* @param {string} userText
* @param {{ subAgents?: boolean }} prefs
*/
export function shouldUseSubAgents(userText, prefs = {}) {
if (prefs.subAgents === false) return false
const q = String(userText || '').toLowerCase()
// Product how-tos: skip multi-agent (use local_knowledge only)
if (
q.includes('how do') ||
q.includes('how to') ||
q.includes('what is qvac') ||
q.includes('keyboard') ||
q.includes('time preset')
) {
return false
}
// Default on for operational questions when feature enabled
if (prefs.subAgents === true || prefs.subAgents == null) {
return (
/health|wrong|diagnos|investigat|summar|status|cpu|ram|memory|disk|alert|anomal|process|load|hot|spik|fleet|storage|retention|metric|chart|nginx|redis|docker|postgres|io\b/.test(
q
) || q.length < 4
)
}
return false
}
/**
* 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,
* onAgent?: (ev: {
* id: string,
* label: string,
* status: 'start'|'done'|'error',
* summary?: string,
* error?: string,
* }) => void,
* }} opts
*/
export async function runSubAgents(opts) {
const specs = pickAgents(opts.query, opts.maxAgents ?? 3)
const tools = opts.tools
/** @type {Array<{ id: string, label: string, status: string, summary: string, result?: any, error?: string }>} */
const agents = []
await Promise.all(
specs.map(async (spec) => {
opts.onAgent?.({ id: spec.id, label: spec.label, status: 'start' })
try {
const result = await spec.run(tools)
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,
status: row.status === 'error' ? 'error' : 'done',
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,
status: 'error',
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')
}