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
+3
View File
@@ -308,6 +308,9 @@ const qvacView = createQvacView({
resetBtn: $('qvac-reset'),
unloadBtn: $('qvac-unload'),
newChatBtn: $('qvac-new-chat'),
settingsBtn: $('qvac-settings-btn'),
settingsPanel: $('qvac-settings-panel'),
agentBar: $('qvac-agent-bar'),
},
manager,
getRole: () => currentRole,
+6
View File
@@ -38,6 +38,9 @@ export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
* qvacCacheDir?: string,
* qvacRag?: boolean,
* qvacIdleUnloadMin?: number,
* qvacSubAgents?: boolean,
* qvacMaxSubAgents?: number,
* qvacToolDepth?: 'auto'|'core'|'deep',
* }} UiSettings */
/** @returns {UiSettings} */
@@ -68,6 +71,9 @@ export function defaultSettings() {
qvacCacheDir: '',
qvacRag: true,
qvacIdleUnloadMin: 30,
qvacSubAgents: true,
qvacMaxSubAgents: 3,
qvacToolDepth: 'auto',
}
}
+57 -1
View File
@@ -355,14 +355,70 @@
<span id="qvac-status" class="muted" data-kind=""></span>
<button type="button" id="qvac-new-chat" class="btn btn-ghost">New chat</button>
<button type="button" id="qvac-unload" class="btn btn-ghost" title="Unload model from memory">Unload</button>
<button type="button" id="qvac-reset" class="btn btn-ghost" title="Re-run onboarding">Setup</button>
<button type="button" id="qvac-settings-btn" class="btn btn-ghost" aria-expanded="false" aria-controls="qvac-settings-panel" title="Model &amp; multi-agent settings">Settings</button>
<button type="button" id="qvac-reset" class="btn btn-ghost" title="First-time setup / re-run onboarding">Setup</button>
</div>
</header>
<div id="qvac-settings-panel" class="qvac-settings-panel hidden" role="region" aria-label="QVAC settings">
<div class="qvac-settings-grid">
<div class="qvac-settings-block">
<h4>Model</h4>
<p id="qvac-settings-model-status" class="muted settings-hint">Status: —</p>
<label class="settings-field">
Profile
<select id="qvac-set-profile">
<option value="lite">Lite</option>
<option value="recommended" selected>Recommended</option>
<option value="strong">Strong</option>
<option value="tool-tiny">Tool-tiny</option>
</select>
</label>
<label class="settings-check">
<input type="checkbox" id="qvac-set-rag" checked />
Inject local knowledge (RAG)
</label>
<label class="settings-field">
Idle unload (minutes, 0 = never)
<input id="qvac-set-idle" type="number" min="0" max="240" step="5" value="30" />
</label>
<label class="settings-field">
Tool depth
<select id="qvac-set-tool-depth">
<option value="auto" selected>Auto (by profile)</option>
<option value="core">Core only</option>
<option value="deep">Deep (full catalog)</option>
</select>
</label>
</div>
<div class="qvac-settings-block">
<h4>Multi-agent</h4>
<p class="muted settings-hint">
Spin up specialist investigators (host, hot metrics, processes, anomalies…) in parallel before the main reply.
</p>
<label class="settings-check">
<input type="checkbox" id="qvac-set-subagents" checked />
Enable multi-agent investigations
</label>
<label class="settings-field">
Max parallel agents
<input id="qvac-set-max-agents" type="number" min="1" max="6" step="1" value="3" />
</label>
</div>
<div class="qvac-settings-block qvac-settings-actions">
<h4>Actions</h4>
<button type="button" id="qvac-set-unload" class="btn btn-ghost">Unload model</button>
<button type="button" id="qvac-set-reload" class="btn btn-ghost" title="Load current profile">Load / reload model</button>
<button type="button" id="qvac-set-rerun-setup" class="btn btn-ghost">Re-run setup</button>
<button type="button" id="qvac-settings-close" class="btn">Done</button>
</div>
</div>
</div>
<div id="qvac-setup" class="qvac-setup">
<div id="qvac-setup-steps" class="qvac-setup-steps"></div>
</div>
<div id="qvac-chat" class="qvac-chat hidden">
<div id="qvac-samples" class="qvac-samples" aria-label="Sample prompts"></div>
<div id="qvac-agent-bar" class="qvac-agent-bar hidden" aria-live="polite"></div>
<div id="qvac-messages" class="qvac-messages" aria-live="polite"></div>
<form id="qvac-form" class="qvac-composer" onsubmit="return false">
<textarea id="qvac-input" rows="2" placeholder="Ask about host health, metrics, anomalies, processes…" autocomplete="off"></textarea>
+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')
}
+28 -4
View File
@@ -445,7 +445,11 @@ export function createQvacEngine(deps) {
const profile = getProfile(profileId || 'recommended')
const prefs = deps.getPrefs?.() || {}
const userLast = [...history].reverse().find((m) => m.role === 'user')
let system = buildSystemPrompt(deps.getContext?.() || {})
const ctx = {
...(deps.getContext?.() || {}),
subAgents: prefs.subAgents !== false,
}
let system = buildSystemPrompt(ctx)
// Light RAG only — full catalog dumps blow small context windows
if (prefs.rag !== false && userLast?.content) {
@@ -457,16 +461,31 @@ export function createQvacEngine(deps) {
if (rag) system += `\n\n${rag.slice(0, 1200)}`
}
// Multi-agent briefing from parallel specialists (optional)
if (opts.agentBriefing) {
system += `\n\n${String(opts.agentBriefing).slice(0, 5000)}`
}
const fullHistory = [
{ role: 'system', content: system },
...history.filter((m) => m.role !== 'system'),
]
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
return completeWithSdk(fullHistory, profile, opts)
return completeWithSdk(fullHistory, profile, opts, prefs)
}
status = status === 'ready' ? status : 'fallback'
// If multi-agent pack already ran, prefer its synthesis over naive fallback
if (opts.agentFallbackText) {
const text = String(opts.agentFallbackText)
if (opts.onToken) {
const chunk = 24
for (let i = 0; i < text.length; i += chunk) opts.onToken(text.slice(i, i + chunk))
}
opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' })
return { contentText: text, toolCalls: opts.agentToolCalls || [], mode: 'fallback' }
}
const result = await fallbackComplete(userLast?.content || '', deps.tools, {
catalog: deps.getCatalog?.() || {},
rag: prefs.rag !== false,
@@ -485,9 +504,14 @@ export function createQvacEngine(deps) {
return result
}
async function completeWithSdk(history, profile, opts) {
async function completeWithSdk(history, profile, opts, prefs = {}) {
const depthPref = prefs.toolDepth || 'auto'
const depth =
depthPref === 'core' || depthPref === 'deep'
? depthPref
: undefined
let toolDefs = profile.tools
? deps.tools.defsForRole({ profileId: profile.id, profile })
? deps.tools.defsForRole({ profileId: profile.id, profile, depth })
: undefined
const ctxSize = profile.ctxSize || 8192
let toolsTok = estimateToolsTokens(toolDefs)
+289 -12
View File
@@ -6,6 +6,11 @@ import { createToolRunner } from './tools.js'
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
import { SAMPLE_PROMPTS } from './prompts.js'
import { partitionThink, renderAssistantHtml } from './think.js'
import {
shouldUseSubAgents,
runSubAgents,
synthesizeFromAgents,
} from './agents.js'
/**
* @param {{
@@ -20,6 +25,9 @@ import { partitionThink, renderAssistantHtml } from './think.js'
* modelChip: HTMLElement|null,
* samples: HTMLElement|null,
* setupSteps: HTMLElement|null,
* agentBar?: HTMLElement|null,
* settingsBtn?: HTMLElement|null,
* settingsPanel?: HTMLElement|null,
* resetBtn?: HTMLElement|null,
* unloadBtn?: HTMLElement|null,
* newChatBtn?: HTMLElement|null,
@@ -68,11 +76,15 @@ export function createQvacView(opts) {
peerId: opts.manager.active?.publicKeyHex || '',
role: opts.getRole?.() || 'viewer',
connected: Boolean(opts.isConnected?.()),
subAgents: settings().qvacSubAgents !== false,
}),
getCatalog: () => opts.getCatalog?.() || {},
getPrefs: () => ({
rag: settings().qvacRag !== false,
idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0,
subAgents: settings().qvacSubAgents !== false,
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
toolDepth: settings().qvacToolDepth || 'auto',
}),
log: opts.log,
})
@@ -95,18 +107,24 @@ export function createQvacView(opts) {
el.dataset.kind = kind
}
function isModelLoaded() {
const st = engine.getStatus()
return st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac' || settings().qvacMode === 'qvac')
}
function syncModelChip() {
const chip = opts.els.modelChip
if (!chip) return
const st = engine.getStatus()
const profile = getProfile(settings().qvacProfile || selectedProfile)
if (st.mode === 'fallback' || (!st.sdkAvailable && st.status !== 'ready')) {
if (st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac')) {
chip.textContent = profile.chatModel
chip.dataset.mode = 'ready'
chip.title = 'Model loaded'
} else if (st.mode === 'fallback' || (!st.sdkAvailable && st.status !== 'ready')) {
chip.textContent = 'tools-only'
chip.dataset.mode = 'fallback'
chip.title = st.sdkError || 'Install @qvac/sdk for full local LLM'
} else if (st.status === 'ready') {
chip.textContent = profile.chatModel
chip.dataset.mode = 'ready'
} else if (st.status === 'downloading' || st.status === 'loading') {
const pct = st.progress?.percentage
chip.textContent =
@@ -116,6 +134,35 @@ export function createQvacView(opts) {
chip.textContent = st.status || 'idle'
chip.dataset.mode = st.status || 'idle'
}
syncHeaderActions()
syncSettingsForm()
}
/**
* Header: always Settings when onboarded.
* Setup only when model is NOT loaded (first-time / tools-only).
* When loaded, re-run setup lives inside the settings panel.
*/
function syncHeaderActions() {
const reset = opts.els.resetBtn
const settingsBtn = opts.els.settingsBtn
const unload = opts.els.unloadBtn
if (!isOnboarded()) {
// Still in wizard — Setup is the primary path; Settings optional if present
reset?.classList.add('hidden')
settingsBtn?.classList.add('hidden')
unload?.classList.add('hidden')
return
}
settingsBtn?.classList.remove('hidden')
const loaded = isModelLoaded()
if (loaded) {
reset?.classList.add('hidden')
unload?.classList.remove('hidden')
} else {
reset?.classList.remove('hidden')
unload?.classList.add('hidden')
}
}
function showPane() {
@@ -127,8 +174,123 @@ export function createQvacView(opts) {
} else {
setup?.classList.remove('hidden')
chat?.classList.add('hidden')
closeSettings()
renderWizard()
}
syncHeaderActions()
}
function openSettings() {
const panel = opts.els.settingsPanel
const btn = opts.els.settingsBtn
if (!panel) return
panel.classList.remove('hidden')
btn?.setAttribute('aria-expanded', 'true')
syncSettingsForm()
}
function closeSettings() {
const panel = opts.els.settingsPanel
const btn = opts.els.settingsBtn
panel?.classList.add('hidden')
btn?.setAttribute('aria-expanded', 'false')
}
function toggleSettings() {
const panel = opts.els.settingsPanel
if (!panel || panel.classList.contains('hidden')) openSettings()
else closeSettings()
}
function syncSettingsForm() {
const s = settings()
const root = opts.els.root
if (!root) return
const st = engine.getStatus()
const profile = getProfile(s.qvacProfile || selectedProfile)
const statusEl = root.querySelector('#qvac-settings-model-status')
if (statusEl) {
const loaded = isModelLoaded()
const multi = s.qvacSubAgents !== false
statusEl.textContent = [
`Status: ${st.status || 'idle'}`,
loaded ? `model=${profile.chatModel}` : 'no model loaded',
s.qvacMode ? `mode=${s.qvacMode}` : '',
multi ? 'multi-agent=on' : 'multi-agent=off',
]
.filter(Boolean)
.join(' · ')
}
const set = (sel, val, isCheck) => {
const el = root.querySelector(sel)
if (!el) return
if (isCheck) el.checked = Boolean(val)
else el.value = String(val ?? '')
}
set('#qvac-set-profile', s.qvacProfile || 'recommended')
set('#qvac-set-rag', s.qvacRag !== false, true)
set('#qvac-set-idle', s.qvacIdleUnloadMin ?? 30)
set('#qvac-set-tool-depth', s.qvacToolDepth || 'auto')
set('#qvac-set-subagents', s.qvacSubAgents !== false, true)
set('#qvac-set-max-agents', s.qvacMaxSubAgents ?? 3)
}
function readSettingsForm() {
const root = opts.els.root
if (!root) return
const profile = root.querySelector('#qvac-set-profile')?.value || 'recommended'
const rag = root.querySelector('#qvac-set-rag')?.checked !== false
const idle = Number(root.querySelector('#qvac-set-idle')?.value) || 0
const toolDepth = root.querySelector('#qvac-set-tool-depth')?.value || 'auto'
const subAgents = root.querySelector('#qvac-set-subagents')?.checked !== false
let maxAgents = Number(root.querySelector('#qvac-set-max-agents')?.value) || 3
maxAgents = Math.min(6, Math.max(1, maxAgents))
selectedProfile = profile
persist({
qvacProfile: profile,
qvacRag: rag,
qvacIdleUnloadMin: idle,
qvacToolDepth: toolDepth,
qvacSubAgents: subAgents,
qvacMaxSubAgents: maxAgents,
})
}
/** @type {Map<string, HTMLElement>} */
const agentChipEls = new Map()
function clearAgentBar() {
const bar = opts.els.agentBar
if (!bar) return
bar.innerHTML = ''
bar.classList.add('hidden')
agentChipEls.clear()
}
/**
* @param {{ id: string, label: string, status: string, summary?: string, error?: string }} ev
*/
function paintAgentEvent(ev) {
const bar = opts.els.agentBar
if (!bar) return
bar.classList.remove('hidden')
let chip = agentChipEls.get(ev.id)
if (!chip) {
chip = document.createElement('span')
chip.className = 'qvac-agent-chip'
chip.dataset.agent = ev.id
bar.appendChild(chip)
agentChipEls.set(ev.id, chip)
}
chip.dataset.status = ev.status
const tip = ev.summary || ev.error || ''
chip.title = tip
chip.textContent =
ev.status === 'start'
? `${ev.label}`
: ev.status === 'error'
? `${ev.label}`
: `${ev.label}`
}
/** Finish onboarding in tools-only mode (no model download). */
@@ -632,6 +794,7 @@ export function createQvacView(opts) {
let acc = ''
let thinkingAcc = ''
setStatus('Thinking…', 'busy')
clearAgentBar()
try {
// History for the model: clean answers only (no think tags)
const hist = messages
@@ -644,14 +807,57 @@ export function createQvacView(opts) {
})
hist.push({ role: 'user', content: text })
/** @type {{ agentBriefing?: string, agentFallbackText?: string, agentToolCalls?: any[] }} */
const completeOpts = {}
const prefs = {
subAgents: settings().qvacSubAgents !== false,
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
}
if (shouldUseSubAgents(text, prefs) && opts.isConnected?.()) {
setStatus('Spinning up agents…', 'busy')
if (streamBody) {
streamBody.innerHTML = formatMdLite('_Dispatching multi-agent investigation…_')
}
const pack = await runSubAgents({
tools,
query: text,
maxAgents: prefs.maxSubAgents,
onAgent: (ev) => {
paintAgentEvent(ev)
setStatus(`Agent: ${ev.label}`, 'busy')
},
})
completeOpts.agentBriefing = pack.contextText
completeOpts.agentFallbackText = synthesizeFromAgents(text, pack)
completeOpts.agentToolCalls = pack.agents.map((a) => ({
name: `agent:${a.id}`,
args: {},
result: { summary: a.summary, status: a.status },
}))
for (const a of pack.agents) {
toolLog.push({
name: `agent:${a.id}`,
args: {},
result: { summary: a.summary, status: a.status },
})
}
if (assistantUi?.div) {
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
if (chips) chips.remove()
assistantUi.div.appendChild(renderToolChips(toolLog))
}
setStatus('Synthesizing…', 'busy')
}
const result = await engine.complete(hist, {
...completeOpts,
onToken: (t) => {
acc += t
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
},
onThinking: (t) => {
thinkingAcc += t
// If model streams think separately, wrap for partitioner
const raw = thinkingAcc
? `<think>\n${thinkingAcc}\n</think>\n${acc}`
: acc
@@ -660,7 +866,6 @@ export function createQvacView(opts) {
onTool: (name, args, res) => {
toolLog.push({ name, args, result: res })
setStatus(`Tool: ${name}`, 'busy')
// Live tool chips while model works
if (assistantUi?.div) {
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
if (chips) chips.remove()
@@ -678,7 +883,6 @@ export function createQvacView(opts) {
const parts = partitionThink(acc)
const last = messages[messages.length - 1]
if (last?.role === 'assistant') {
// Store full text (with think) for UI re-open; history path strips think
last.content = acc
last.thinking = parts.thinking
last.tools = toolLog
@@ -724,6 +928,45 @@ export function createQvacView(opts) {
showPane()
}
async function unloadModel() {
await engine.unload()
persist({ qvacMode: settings().qvacMode === 'qvac' ? 'fallback' : settings().qvacMode })
setStatus('Model unloaded', 'ok')
syncModelChip()
}
async function reloadModelFromSettings() {
readSettingsForm()
const profile = settings().qvacProfile || selectedProfile || 'recommended'
selectedProfile = profile
setStatus('Loading model…', 'busy')
syncModelChip()
try {
const result = await engine.loadProfile(profile, {
onProgress: (p) => {
const pct = p?.percentage
if (pct != null) setStatus(`Loading model ${Number(pct).toFixed(0)}%…`, 'busy')
syncModelChip()
},
})
persist({
qvacOnboarded: true,
qvacProfile: profile,
qvacMode: result.mode || 'fallback',
})
setStatus(
result.ok && result.mode === 'qvac'
? 'Ready'
: `Ready (tools-only${result.error ? `: ${result.error}` : ''})`,
result.mode === 'qvac' ? 'ok' : 'warn'
)
} catch (err) {
setStatus(`Load failed: ${err?.message || err}`, 'error')
}
syncModelChip()
showPane()
}
function bind() {
opts.els.sendBtn?.addEventListener('click', () => send())
opts.els.input?.addEventListener('keydown', (ev) => {
@@ -732,13 +975,47 @@ export function createQvacView(opts) {
send()
}
})
opts.els.resetBtn?.addEventListener('click', () => resetOnboarding())
opts.els.unloadBtn?.addEventListener('click', async () => {
await engine.unload()
setStatus('Model unloaded', 'ok')
opts.els.resetBtn?.addEventListener('click', () => {
closeSettings()
resetOnboarding()
})
opts.els.unloadBtn?.addEventListener('click', () => unloadModel())
opts.els.newChatBtn?.addEventListener('click', () => newChat())
opts.els.settingsBtn?.addEventListener('click', () => toggleSettings())
const root = opts.els.root
root?.querySelector('#qvac-settings-close')?.addEventListener('click', () => {
readSettingsForm()
closeSettings()
setStatus('Settings saved', 'ok')
syncModelChip()
})
opts.els.newChatBtn?.addEventListener('click', () => newChat())
root?.querySelector('#qvac-set-rerun-setup')?.addEventListener('click', () => {
readSettingsForm()
closeSettings()
resetOnboarding()
})
root?.querySelector('#qvac-set-unload')?.addEventListener('click', () => {
readSettingsForm()
unloadModel()
})
root?.querySelector('#qvac-set-reload')?.addEventListener('click', () => {
reloadModelFromSettings()
})
// Live-persist toggles
for (const sel of [
'#qvac-set-profile',
'#qvac-set-rag',
'#qvac-set-idle',
'#qvac-set-tool-depth',
'#qvac-set-subagents',
'#qvac-set-max-agents',
]) {
root?.querySelector(sel)?.addEventListener('change', () => {
readSettingsForm()
syncSettingsForm()
})
}
}
/** @type {Promise<void>|null} */
+10 -2
View File
@@ -9,12 +9,14 @@
* role?: string,
* connected?: boolean,
* hostname?: string,
* subAgents?: boolean,
* }} ctx
*/
export function buildSystemPrompt(ctx = {}) {
const peer = ctx.peerAlias || (ctx.peerId ? `${String(ctx.peerId).slice(0, 12)}` : 'none')
const role = ctx.role || 'viewer'
const conn = ctx.connected ? 'connected' : 'disconnected'
const multi = ctx.subAgents !== false
return [
'You are PearData QVAC, a local-only SRE copilot for a P2P host monitoring agent.',
'You run on the operator desktop; metrics come from the connected agent via tools.',
@@ -27,6 +29,9 @@ export function buildSystemPrompt(ctx = {}) {
'- You are read-only unless the user explicitly asks for an operator action and their role allows it.',
'- Do not claim cloud access; all inference is local via QVAC.',
'- Prefer open_chart / open_view when the user asks to see something in the UI.',
multi
? '- Multi-agent mode: specialist investigators may already have collected a briefing. Trust those facts; only call extra tools for gaps.'
: '',
'',
'Tool playbook (use these exact tool names and chart ids):',
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
@@ -47,8 +52,10 @@ export function buildSystemPrompt(ctx = {}) {
'- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.',
'- After tools return, give a clear human summary with numbers; do not only restate empty charts.',
'',
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}.`,
].join('\n')
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}${multi ? ', multi-agent=on' : ''}.`,
]
.filter(Boolean)
.join('\n')
}
export const SAMPLE_PROMPTS = [
@@ -59,6 +66,7 @@ export const SAMPLE_PROMPTS = [
'List charts related to disk or io',
'Any open alerts or anomalies?',
'Top processes by CPU if available',
'Run a multi-agent investigation of this host',
'How do Charts time presets work?',
'What is retention / warm history?',
]
+98
View File
@@ -3659,6 +3659,104 @@ html[data-theme='light'] .proc-detail-cmd {
gap: 8px;
}
.qvac-header-actions .hidden {
display: none !important;
}
.qvac-settings-panel {
margin: 0 0 4px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.qvac-settings-panel.hidden {
display: none !important;
}
.qvac-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px 20px;
}
.qvac-settings-block h4 {
margin: 0 0 8px;
font-size: 0.8rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-secondary);
}
.qvac-settings-block .settings-field,
.qvac-settings-block .settings-check {
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px 0;
font-size: 0.9rem;
}
.qvac-settings-block .settings-check {
flex-direction: row;
align-items: center;
gap: 8px;
}
.qvac-settings-block select,
.qvac-settings-block input[type='number'] {
max-width: 100%;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary, var(--bg));
color: inherit;
}
.qvac-settings-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.qvac-agent-bar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 2px 8px;
}
.qvac-agent-bar.hidden {
display: none !important;
}
.qvac-agent-chip {
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--accent-primary) 12%, transparent);
color: var(--text-primary, var(--text));
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qvac-agent-chip[data-status='start'] {
border-color: color-mix(in srgb, #60a5fa 50%, var(--border-color));
}
.qvac-agent-chip[data-status='done'] {
border-color: color-mix(in srgb, #34d399 50%, var(--border-color));
}
.qvac-agent-chip[data-status='error'] {
border-color: color-mix(in srgb, #f87171 55%, var(--border-color));
}
.qvac-model-chip {
font-family: var(--font-mono);
font-size: 11px;