First Try: QVAC (QuantumVerse Automatic Computer)
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* QVAC tool schemas + handlers → PearData RPC / UI navigation.
|
||||
*/
|
||||
import { Methods, Roles, roleAllows } from '../../shared/protocol.js'
|
||||
import { buildRagContext } from './rag.js'
|
||||
|
||||
/**
|
||||
* OpenAI-style tool definitions for QVAC completion({ tools }).
|
||||
*/
|
||||
export const TOOL_DEFS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'host_snapshot',
|
||||
description:
|
||||
'Get a compact live snapshot: health, KPIs (cpu/ram/load/net/io), recent anomalies/alerts, catalog size.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_charts',
|
||||
description: 'Search the metrics chart catalog by free text (id, title, context, family).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Search query' },
|
||||
limit: { type: 'number', description: 'Max results (default 20)' },
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'summarize_chart',
|
||||
description: 'Summarize one chart: min/avg/max/last per dimension for a time window.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chart: { type: 'string', description: 'Chart id e.g. system.cpu' },
|
||||
after: { type: 'number', description: 'Seconds relative (e.g. -300) or absolute unix' },
|
||||
points: { type: 'number', description: 'Max points (default 90)' },
|
||||
},
|
||||
required: ['chart'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'query_metric',
|
||||
description: 'Raw queryData for a chart time series.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chart: { type: 'string' },
|
||||
after: { type: 'number' },
|
||||
points: { type: 'number' },
|
||||
group: { type: 'string', enum: ['average', 'min', 'max', 'sum'] },
|
||||
},
|
||||
required: ['chart'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_anomalies',
|
||||
description: 'List recent anomaly events.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { limit: { type: 'number' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_alerts',
|
||||
description: 'List configured/open alerts on the agent.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_processes',
|
||||
description: 'Live process table (when agent enables PEARDATA_PROCESSES).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sort: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
filter: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'query_logs',
|
||||
description: 'Query agent logs: source journal|anomaly|audit, optional free-text q.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string', enum: ['journal', 'anomaly', 'audit'] },
|
||||
q: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'fleet_health',
|
||||
description: 'Fleet / parent-child health summary when parent mode is enabled.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'storage_info',
|
||||
description: 'Agent storage usage and retention config.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'local_knowledge',
|
||||
description:
|
||||
'Search local PearData operator knowledge and chart catalog (no network). Use for product how-to questions.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string' },
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'open_chart',
|
||||
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chart: { type: 'string' },
|
||||
ts: { type: 'number', description: 'Event time ms' },
|
||||
},
|
||||
required: ['chart'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'open_view',
|
||||
description:
|
||||
'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
view: { type: 'string' },
|
||||
},
|
||||
required: ['view'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'silence_alert',
|
||||
description:
|
||||
'Operator only: silence an alert by id for durationMs (requires confirm). Prefer explaining first.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
durationMs: { type: 'number', description: 'Silence duration ms (default 3600000)' },
|
||||
confirmed: { type: 'boolean', description: 'Must be true after user confirms' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* manager: { request: (m: string, a?: object) => Promise<any>, active: any },
|
||||
* getRole: () => string,
|
||||
* isConnected: () => boolean,
|
||||
* getCatalog?: () => Record<string, object>,
|
||||
* onOpenChart?: (chartId: string, ts?: number) => void,
|
||||
* onOpenView?: (view: string) => void,
|
||||
* confirmAction?: (message: string) => boolean|Promise<boolean>,
|
||||
* }} deps
|
||||
*/
|
||||
export function createToolRunner(deps) {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {object} args
|
||||
*/
|
||||
async function run(name, args = {}) {
|
||||
const localOk = ['open_view', 'open_chart', 'local_knowledge'].includes(name)
|
||||
if (!deps.isConnected?.() && !localOk) {
|
||||
return { error: 'No agent connected. Connect from the Connect tab first.' }
|
||||
}
|
||||
const req = (m, a) => deps.manager.request(m, a || {})
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'host_snapshot':
|
||||
return await req(Methods.getHostSnapshot, {})
|
||||
case 'search_charts':
|
||||
return await req(Methods.searchCharts, {
|
||||
q: args.q || '',
|
||||
limit: args.limit ?? 20,
|
||||
})
|
||||
case 'summarize_chart':
|
||||
return await req(Methods.summarizeChart, {
|
||||
chart: args.chart || args.id,
|
||||
after: args.after,
|
||||
points: args.points,
|
||||
group: args.group,
|
||||
})
|
||||
case 'query_metric':
|
||||
return await req(Methods.queryData, {
|
||||
chart: args.chart,
|
||||
after: args.after ?? -90,
|
||||
points: args.points ?? 90,
|
||||
group: args.group || 'average',
|
||||
})
|
||||
case 'list_anomalies':
|
||||
return await req(Methods.listAnomalies, { limit: args.limit ?? 30 })
|
||||
case 'list_alerts':
|
||||
return await req(Methods.listAlerts, {})
|
||||
case 'list_processes':
|
||||
return await req(Methods.listProcesses, {
|
||||
sort: args.sort || 'cpu',
|
||||
limit: args.limit ?? 25,
|
||||
filter: args.filter || 'all',
|
||||
})
|
||||
case 'query_logs':
|
||||
return await req(Methods.queryLogs, {
|
||||
source: args.source || 'anomaly',
|
||||
q: args.q || '',
|
||||
limit: args.limit ?? 40,
|
||||
})
|
||||
case 'fleet_health':
|
||||
return await req(Methods.getFleetHealth, {})
|
||||
case 'storage_info': {
|
||||
const [storage, retention] = await Promise.all([
|
||||
req(Methods.getStorageInfo, {}),
|
||||
req(Methods.getRetentionConfig, {}),
|
||||
])
|
||||
return { storage, retention }
|
||||
}
|
||||
case 'local_knowledge': {
|
||||
const ctx = buildRagContext({
|
||||
query: String(args.q || ''),
|
||||
catalog: deps.getCatalog?.() || {},
|
||||
topK: 8,
|
||||
})
|
||||
return { context: ctx || 'No matching local knowledge.', q: args.q }
|
||||
}
|
||||
case 'open_chart': {
|
||||
deps.onOpenChart?.(String(args.chart), args.ts)
|
||||
return { ok: true, opened: args.chart }
|
||||
}
|
||||
case 'open_view': {
|
||||
deps.onOpenView?.(String(args.view || 'overview'))
|
||||
return { ok: true, view: args.view }
|
||||
}
|
||||
case 'silence_alert': {
|
||||
const role = deps.getRole?.() || Roles.viewer
|
||||
if (!roleAllows(role, Roles.operator)) {
|
||||
return { error: 'Operator role required to silence alerts' }
|
||||
}
|
||||
if (!args.confirmed) {
|
||||
return {
|
||||
error: 'confirmation_required',
|
||||
message: `Confirm silencing alert ${args.id} before retrying with confirmed=true`,
|
||||
}
|
||||
}
|
||||
const ok =
|
||||
(await deps.confirmAction?.(
|
||||
`Silence alert ${args.id} for ${Math.round((args.durationMs || 3_600_000) / 60000)} minutes?`
|
||||
)) !== false
|
||||
if (!ok) return { error: 'User declined silence' }
|
||||
return await req(Methods.silenceAlert, {
|
||||
id: args.id,
|
||||
durationMs: args.durationMs ?? 3_600_000,
|
||||
})
|
||||
}
|
||||
default:
|
||||
return { error: `Unknown tool: ${name}` }
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: err?.message || String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools available for the current role.
|
||||
*/
|
||||
function defsForRole() {
|
||||
const role = deps.getRole?.() || Roles.viewer
|
||||
if (roleAllows(role, Roles.operator)) return TOOL_DEFS
|
||||
return TOOL_DEFS.filter((t) => t.function.name !== 'silence_alert')
|
||||
}
|
||||
|
||||
return { run, defsForRole, TOOL_DEFS }
|
||||
}
|
||||
|
||||
/**
|
||||
* Naive tool-using fallback when QVAC SDK is not installed.
|
||||
* @param {string} userText
|
||||
* @param {{ run: (name: string, args?: object) => Promise<any> }} tools
|
||||
* @param {{ catalog?: Record<string, object>, rag?: boolean }} [opts]
|
||||
*/
|
||||
export async function fallbackComplete(userText, tools, opts = {}) {
|
||||
const q = String(userText || '').toLowerCase()
|
||||
/** @type {Array<{ name: string, args: object, result: any }>} */
|
||||
const calls = []
|
||||
|
||||
// Product / how-to questions can skip live snapshot
|
||||
const howTo =
|
||||
q.includes('how do') ||
|
||||
q.includes('what is qvac') ||
|
||||
q.includes('how to') ||
|
||||
q.includes('keyboard') ||
|
||||
q.includes('retention')
|
||||
|
||||
if (!howTo) {
|
||||
const snap = await tools.run('host_snapshot', {})
|
||||
calls.push({ name: 'host_snapshot', args: {}, result: snap })
|
||||
}
|
||||
|
||||
if (opts.rag !== false) {
|
||||
const kn = await tools.run('local_knowledge', { q: userText })
|
||||
calls.push({ name: 'local_knowledge', args: { q: userText }, result: kn })
|
||||
}
|
||||
|
||||
if (q.includes('process') || q.includes('top ')) {
|
||||
const p = await tools.run('list_processes', { limit: 10 })
|
||||
calls.push({ name: 'list_processes', args: { limit: 10 }, result: p })
|
||||
}
|
||||
if (
|
||||
q.includes('chart') ||
|
||||
q.includes('metric') ||
|
||||
q.includes('disk') ||
|
||||
q.includes('redis') ||
|
||||
q.includes('docker') ||
|
||||
q.includes('nginx') ||
|
||||
q.includes('postgres')
|
||||
) {
|
||||
const term =
|
||||
(q.match(/\b(redis|docker|disk|cpu|ram|net|io|postgres|nginx|mem)\b/) || [])[0] ||
|
||||
'system'
|
||||
const s = await tools.run('search_charts', { q: term, limit: 12 })
|
||||
calls.push({ name: 'search_charts', args: { q: term }, result: s })
|
||||
}
|
||||
if (q.includes('anomal') || q.includes('alert')) {
|
||||
const a = await tools.run('list_anomalies', { limit: 15 })
|
||||
calls.push({ name: 'list_anomalies', args: {}, result: a })
|
||||
if (q.includes('alert')) {
|
||||
const al = await tools.run('list_alerts', {})
|
||||
calls.push({ name: 'list_alerts', args: {}, result: al })
|
||||
}
|
||||
}
|
||||
if (q.includes('fleet') || q.includes('child')) {
|
||||
const f = await tools.run('fleet_health', {})
|
||||
calls.push({ name: 'fleet_health', args: {}, result: f })
|
||||
}
|
||||
if (q.includes('log') || q.includes('journal')) {
|
||||
const l = await tools.run('query_logs', { source: 'anomaly', limit: 20 })
|
||||
calls.push({ name: 'query_logs', args: { source: 'anomaly' }, result: l })
|
||||
}
|
||||
if (q.includes('storage') || q.includes('retention') || q.includes('prune')) {
|
||||
const s = await tools.run('storage_info', {})
|
||||
calls.push({ name: 'storage_info', args: {}, result: s })
|
||||
}
|
||||
if (/\bcpu\b/.test(q) || q.includes('load')) {
|
||||
const s = await tools.run('summarize_chart', { chart: 'system.cpu', points: 60 })
|
||||
calls.push({ name: 'summarize_chart', args: { chart: 'system.cpu' }, result: s })
|
||||
}
|
||||
if (/\bram\b/.test(q) || q.includes('memory')) {
|
||||
const s = await tools.run('summarize_chart', { chart: 'system.ram', points: 60 })
|
||||
calls.push({ name: 'summarize_chart', args: { chart: 'system.ram' }, result: s })
|
||||
}
|
||||
|
||||
const snap = calls.find((c) => c.name === 'host_snapshot')?.result || null
|
||||
const text = formatFallbackAnswer(userText, snap, calls, howTo)
|
||||
return { contentText: text, toolCalls: calls, mode: 'fallback' }
|
||||
}
|
||||
|
||||
function formatFallbackAnswer(userText, snap, calls, howTo) {
|
||||
if (!howTo && snap?.error) {
|
||||
return `I could not reach the agent: ${snap.error}\n\nConnect an agent from the Connect tab, then ask again.\n\n(Running in **tools-only fallback** — install @qvac/sdk for full local LLM chat.)`
|
||||
}
|
||||
|
||||
const lines = []
|
||||
if (snap && !snap.error) {
|
||||
lines.push(`**Host snapshot** (${snap.hostname || 'agent'})`)
|
||||
if (snap.health) {
|
||||
lines.push(
|
||||
`- Health: status=${snap.health.status || '—'}, warnings=${snap.health.warnings ?? '—'}, critical=${snap.health.critical ?? '—'}`
|
||||
)
|
||||
}
|
||||
if (snap.kpis) {
|
||||
for (const [k, v] of Object.entries(snap.kpis)) {
|
||||
if (!v) continue
|
||||
lines.push(`- ${k}: **${fmt(v.value)}** (${v.chart}.${v.dim})`)
|
||||
}
|
||||
}
|
||||
if (snap.anomalies?.length) {
|
||||
lines.push(`- Recent anomalies: ${snap.anomalies.length}`)
|
||||
for (const a of snap.anomalies.slice(0, 5)) {
|
||||
lines.push(` · ${a.severity || '?'} ${a.chart || ''} — ${a.message || ''}`)
|
||||
}
|
||||
} else {
|
||||
lines.push('- No recent anomalies in the snapshot.')
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of calls) {
|
||||
if (c.name === 'host_snapshot') continue
|
||||
if (c.name === 'local_knowledge' && c.result?.context) {
|
||||
lines.push(`\n**Local knowledge**\n${c.result.context}`)
|
||||
}
|
||||
if (c.name === 'search_charts' && c.result?.results) {
|
||||
lines.push(`\n**Charts matching “${c.args.q}”** (${c.result.results.length})`)
|
||||
for (const r of c.result.results.slice(0, 8)) {
|
||||
lines.push(`- \`${r.id}\` — ${r.title || ''}`)
|
||||
}
|
||||
}
|
||||
if (c.name === 'summarize_chart' && c.result?.dims) {
|
||||
lines.push(`\n**${c.result.chart}** (${c.result.points} pts, source=${c.result.source})`)
|
||||
for (const [dim, st] of Object.entries(c.result.dims)) {
|
||||
lines.push(
|
||||
`- ${dim}: last=${fmt(st.last)} avg=${fmt(st.avg)} min=${fmt(st.min)} max=${fmt(st.max)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (c.name === 'list_processes' && c.result?.processes) {
|
||||
lines.push('\n**Top processes**')
|
||||
for (const p of (c.result.processes || []).slice(0, 8)) {
|
||||
lines.push(
|
||||
`- pid ${p.pid} ${p.name || p.comm || ''} cpu=${fmt(p.cpu)}% rss=${fmt(p.rss)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (c.name === 'list_anomalies' && c.result?.anomalies) {
|
||||
lines.push(`\n**Anomalies** (${c.result.anomalies.length})`)
|
||||
for (const a of c.result.anomalies.slice(0, 8)) {
|
||||
lines.push(`- ${a.severity} ${a.chart}: ${a.message || ''}`)
|
||||
}
|
||||
}
|
||||
if (c.name === 'list_alerts' && c.result?.alerts) {
|
||||
lines.push(`\n**Alerts** (${c.result.alerts.length})`)
|
||||
for (const a of (c.result.alerts || []).slice(0, 8)) {
|
||||
lines.push(`- ${a.id || a.chart}: ${a.severity || ''} ${a.message || a.name || ''}`)
|
||||
}
|
||||
}
|
||||
if (c.name === 'fleet_health' && c.result) {
|
||||
lines.push(
|
||||
`\n**Fleet** enabled=${c.result.enabled} children=${(c.result.children || []).length}`
|
||||
)
|
||||
if (c.result.summary) {
|
||||
lines.push(`- summary: ${JSON.stringify(c.result.summary)}`)
|
||||
}
|
||||
}
|
||||
if (c.name === 'query_logs' && (c.result?.entries || c.result?.lines || c.result?.events)) {
|
||||
const rows = c.result.entries || c.result.lines || c.result.events || []
|
||||
lines.push(`\n**Logs** (${rows.length})`)
|
||||
for (const row of rows.slice(0, 6)) {
|
||||
const msg = row.message || row.msg || row.line || JSON.stringify(row).slice(0, 120)
|
||||
lines.push(`- ${msg}`)
|
||||
}
|
||||
}
|
||||
if (c.name === 'storage_info' && c.result) {
|
||||
lines.push('\n**Storage / retention**')
|
||||
lines.push('```\n' + JSON.stringify(c.result, null, 0).slice(0, 800) + '\n```')
|
||||
}
|
||||
}
|
||||
|
||||
if (!lines.length) {
|
||||
lines.push('No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).')
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`\n_Question: ${userText}_\n_Mode: tools-only fallback (install **@qvac/sdk** for Qwen local chat)._`
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function fmt(v) {
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
if (Math.abs(n) >= 100) return n.toFixed(0)
|
||||
if (Math.abs(n) >= 10) return n.toFixed(1)
|
||||
return n.toFixed(2)
|
||||
}
|
||||
Reference in New Issue
Block a user