Multi Agent Investigations
This commit is contained in:
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user