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'), resetBtn: $('qvac-reset'),
unloadBtn: $('qvac-unload'), unloadBtn: $('qvac-unload'),
newChatBtn: $('qvac-new-chat'), newChatBtn: $('qvac-new-chat'),
settingsBtn: $('qvac-settings-btn'),
settingsPanel: $('qvac-settings-panel'),
agentBar: $('qvac-agent-bar'),
}, },
manager, manager,
getRole: () => currentRole, getRole: () => currentRole,
+6
View File
@@ -38,6 +38,9 @@ export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
* qvacCacheDir?: string, * qvacCacheDir?: string,
* qvacRag?: boolean, * qvacRag?: boolean,
* qvacIdleUnloadMin?: number, * qvacIdleUnloadMin?: number,
* qvacSubAgents?: boolean,
* qvacMaxSubAgents?: number,
* qvacToolDepth?: 'auto'|'core'|'deep',
* }} UiSettings */ * }} UiSettings */
/** @returns {UiSettings} */ /** @returns {UiSettings} */
@@ -68,6 +71,9 @@ export function defaultSettings() {
qvacCacheDir: '', qvacCacheDir: '',
qvacRag: true, qvacRag: true,
qvacIdleUnloadMin: 30, 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> <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-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-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> </div>
</header> </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" class="qvac-setup">
<div id="qvac-setup-steps" class="qvac-setup-steps"></div> <div id="qvac-setup-steps" class="qvac-setup-steps"></div>
</div> </div>
<div id="qvac-chat" class="qvac-chat hidden"> <div id="qvac-chat" class="qvac-chat hidden">
<div id="qvac-samples" class="qvac-samples" aria-label="Sample prompts"></div> <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> <div id="qvac-messages" class="qvac-messages" aria-live="polite"></div>
<form id="qvac-form" class="qvac-composer" onsubmit="return false"> <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> <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 profile = getProfile(profileId || 'recommended')
const prefs = deps.getPrefs?.() || {} const prefs = deps.getPrefs?.() || {}
const userLast = [...history].reverse().find((m) => m.role === 'user') 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 // Light RAG only — full catalog dumps blow small context windows
if (prefs.rag !== false && userLast?.content) { if (prefs.rag !== false && userLast?.content) {
@@ -457,16 +461,31 @@ export function createQvacEngine(deps) {
if (rag) system += `\n\n${rag.slice(0, 1200)}` 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 = [ const fullHistory = [
{ role: 'system', content: system }, { role: 'system', content: system },
...history.filter((m) => m.role !== 'system'), ...history.filter((m) => m.role !== 'system'),
] ]
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) { if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
return completeWithSdk(fullHistory, profile, opts) return completeWithSdk(fullHistory, profile, opts, prefs)
} }
status = status === 'ready' ? status : 'fallback' 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, { const result = await fallbackComplete(userLast?.content || '', deps.tools, {
catalog: deps.getCatalog?.() || {}, catalog: deps.getCatalog?.() || {},
rag: prefs.rag !== false, rag: prefs.rag !== false,
@@ -485,9 +504,14 @@ export function createQvacEngine(deps) {
return result 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 let toolDefs = profile.tools
? deps.tools.defsForRole({ profileId: profile.id, profile }) ? deps.tools.defsForRole({ profileId: profile.id, profile, depth })
: undefined : undefined
const ctxSize = profile.ctxSize || 8192 const ctxSize = profile.ctxSize || 8192
let toolsTok = estimateToolsTokens(toolDefs) 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 { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
import { SAMPLE_PROMPTS } from './prompts.js' import { SAMPLE_PROMPTS } from './prompts.js'
import { partitionThink, renderAssistantHtml } from './think.js' import { partitionThink, renderAssistantHtml } from './think.js'
import {
shouldUseSubAgents,
runSubAgents,
synthesizeFromAgents,
} from './agents.js'
/** /**
* @param {{ * @param {{
@@ -20,6 +25,9 @@ import { partitionThink, renderAssistantHtml } from './think.js'
* modelChip: HTMLElement|null, * modelChip: HTMLElement|null,
* samples: HTMLElement|null, * samples: HTMLElement|null,
* setupSteps: HTMLElement|null, * setupSteps: HTMLElement|null,
* agentBar?: HTMLElement|null,
* settingsBtn?: HTMLElement|null,
* settingsPanel?: HTMLElement|null,
* resetBtn?: HTMLElement|null, * resetBtn?: HTMLElement|null,
* unloadBtn?: HTMLElement|null, * unloadBtn?: HTMLElement|null,
* newChatBtn?: HTMLElement|null, * newChatBtn?: HTMLElement|null,
@@ -68,11 +76,15 @@ export function createQvacView(opts) {
peerId: opts.manager.active?.publicKeyHex || '', peerId: opts.manager.active?.publicKeyHex || '',
role: opts.getRole?.() || 'viewer', role: opts.getRole?.() || 'viewer',
connected: Boolean(opts.isConnected?.()), connected: Boolean(opts.isConnected?.()),
subAgents: settings().qvacSubAgents !== false,
}), }),
getCatalog: () => opts.getCatalog?.() || {}, getCatalog: () => opts.getCatalog?.() || {},
getPrefs: () => ({ getPrefs: () => ({
rag: settings().qvacRag !== false, rag: settings().qvacRag !== false,
idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0, idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0,
subAgents: settings().qvacSubAgents !== false,
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
toolDepth: settings().qvacToolDepth || 'auto',
}), }),
log: opts.log, log: opts.log,
}) })
@@ -95,18 +107,24 @@ export function createQvacView(opts) {
el.dataset.kind = kind el.dataset.kind = kind
} }
function isModelLoaded() {
const st = engine.getStatus()
return st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac' || settings().qvacMode === 'qvac')
}
function syncModelChip() { function syncModelChip() {
const chip = opts.els.modelChip const chip = opts.els.modelChip
if (!chip) return if (!chip) return
const st = engine.getStatus() const st = engine.getStatus()
const profile = getProfile(settings().qvacProfile || selectedProfile) 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.textContent = 'tools-only'
chip.dataset.mode = 'fallback' chip.dataset.mode = 'fallback'
chip.title = st.sdkError || 'Install @qvac/sdk for full local LLM' 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') { } else if (st.status === 'downloading' || st.status === 'loading') {
const pct = st.progress?.percentage const pct = st.progress?.percentage
chip.textContent = chip.textContent =
@@ -116,6 +134,35 @@ export function createQvacView(opts) {
chip.textContent = st.status || 'idle' chip.textContent = st.status || 'idle'
chip.dataset.mode = 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() { function showPane() {
@@ -127,8 +174,123 @@ export function createQvacView(opts) {
} else { } else {
setup?.classList.remove('hidden') setup?.classList.remove('hidden')
chat?.classList.add('hidden') chat?.classList.add('hidden')
closeSettings()
renderWizard() 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). */ /** Finish onboarding in tools-only mode (no model download). */
@@ -632,6 +794,7 @@ export function createQvacView(opts) {
let acc = '' let acc = ''
let thinkingAcc = '' let thinkingAcc = ''
setStatus('Thinking…', 'busy') setStatus('Thinking…', 'busy')
clearAgentBar()
try { try {
// History for the model: clean answers only (no think tags) // History for the model: clean answers only (no think tags)
const hist = messages const hist = messages
@@ -644,14 +807,57 @@ export function createQvacView(opts) {
}) })
hist.push({ role: 'user', content: text }) 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, { const result = await engine.complete(hist, {
...completeOpts,
onToken: (t) => { onToken: (t) => {
acc += t acc += t
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true) paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
}, },
onThinking: (t) => { onThinking: (t) => {
thinkingAcc += t thinkingAcc += t
// If model streams think separately, wrap for partitioner
const raw = thinkingAcc const raw = thinkingAcc
? `<think>\n${thinkingAcc}\n</think>\n${acc}` ? `<think>\n${thinkingAcc}\n</think>\n${acc}`
: acc : acc
@@ -660,7 +866,6 @@ export function createQvacView(opts) {
onTool: (name, args, res) => { onTool: (name, args, res) => {
toolLog.push({ name, args, result: res }) toolLog.push({ name, args, result: res })
setStatus(`Tool: ${name}`, 'busy') setStatus(`Tool: ${name}`, 'busy')
// Live tool chips while model works
if (assistantUi?.div) { if (assistantUi?.div) {
let chips = assistantUi.div.querySelector('.qvac-tool-chips') let chips = assistantUi.div.querySelector('.qvac-tool-chips')
if (chips) chips.remove() if (chips) chips.remove()
@@ -678,7 +883,6 @@ export function createQvacView(opts) {
const parts = partitionThink(acc) const parts = partitionThink(acc)
const last = messages[messages.length - 1] const last = messages[messages.length - 1]
if (last?.role === 'assistant') { if (last?.role === 'assistant') {
// Store full text (with think) for UI re-open; history path strips think
last.content = acc last.content = acc
last.thinking = parts.thinking last.thinking = parts.thinking
last.tools = toolLog last.tools = toolLog
@@ -724,6 +928,45 @@ export function createQvacView(opts) {
showPane() 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() { function bind() {
opts.els.sendBtn?.addEventListener('click', () => send()) opts.els.sendBtn?.addEventListener('click', () => send())
opts.els.input?.addEventListener('keydown', (ev) => { opts.els.input?.addEventListener('keydown', (ev) => {
@@ -732,13 +975,47 @@ export function createQvacView(opts) {
send() send()
} }
}) })
opts.els.resetBtn?.addEventListener('click', () => resetOnboarding()) opts.els.resetBtn?.addEventListener('click', () => {
opts.els.unloadBtn?.addEventListener('click', async () => { closeSettings()
await engine.unload() resetOnboarding()
setStatus('Model unloaded', 'ok') })
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() 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} */ /** @type {Promise<void>|null} */
+10 -2
View File
@@ -9,12 +9,14 @@
* role?: string, * role?: string,
* connected?: boolean, * connected?: boolean,
* hostname?: string, * hostname?: string,
* subAgents?: boolean,
* }} ctx * }} ctx
*/ */
export function buildSystemPrompt(ctx = {}) { export function buildSystemPrompt(ctx = {}) {
const peer = ctx.peerAlias || (ctx.peerId ? `${String(ctx.peerId).slice(0, 12)}` : 'none') const peer = ctx.peerAlias || (ctx.peerId ? `${String(ctx.peerId).slice(0, 12)}` : 'none')
const role = ctx.role || 'viewer' const role = ctx.role || 'viewer'
const conn = ctx.connected ? 'connected' : 'disconnected' const conn = ctx.connected ? 'connected' : 'disconnected'
const multi = ctx.subAgents !== false
return [ return [
'You are PearData QVAC, a local-only SRE copilot for a P2P host monitoring agent.', '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.', '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.', '- 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.', '- 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.', '- 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):', 'Tool playbook (use these exact tool names and chart ids):',
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).', '- 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.', '- 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.', '- 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` : ''}.`, `Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}${multi ? ', multi-agent=on' : ''}.`,
].join('\n') ]
.filter(Boolean)
.join('\n')
} }
export const SAMPLE_PROMPTS = [ export const SAMPLE_PROMPTS = [
@@ -59,6 +66,7 @@ export const SAMPLE_PROMPTS = [
'List charts related to disk or io', 'List charts related to disk or io',
'Any open alerts or anomalies?', 'Any open alerts or anomalies?',
'Top processes by CPU if available', 'Top processes by CPU if available',
'Run a multi-agent investigation of this host',
'How do Charts time presets work?', 'How do Charts time presets work?',
'What is retention / warm history?', 'What is retention / warm history?',
] ]
+98
View File
@@ -3659,6 +3659,104 @@ html[data-theme='light'] .proc-detail-cmd {
gap: 8px; 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 { .qvac-model-chip {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;