FIX: QVAC Updates
CI / test (push) Successful in 2m45s
Release rolling / release (push) Successful in 11m36s

This commit is contained in:
Raven Scott
2026-07-30 15:23:43 -04:00
parent 79250e1a55
commit 4b5b78892f
25 changed files with 2886 additions and 345 deletions
+605 -172
View File
@@ -1,199 +1,472 @@
/**
* QVAC tool schemas + handlers → PearData RPC / UI navigation.
*
* Tool defs use the **@qvac/sdk** wire shape (flat), not OpenAI nested
* `{ type, function: { name, parameters } }`:
* { type: 'function', name, description, parameters: { type:'object', properties, required? } }
* Property values may only include type / description / enum (see @qvac/sdk toolSchema).
*
* Tools are tiered so small-context models stay lean:
* core → always (tool-tiny + recommended + strong)
* deep → recommended + strong (investigation / fleet / storage)
* write → operator+ only (silence / ack / run_job)
*/
import { Methods, Roles, roleAllows } from '../../shared/protocol.js'
import { buildRagContext } from './rag.js'
/** @param {Record<string, { type: string, description?: string, enum?: any[] }>} properties */
function params(properties = {}, required) {
const out = {
type: 'object',
properties: properties || {},
}
if (required?.length) out.required = required
return out
}
/**
* OpenAI-style tool definitions for QVAC completion({ tools }).
* @typedef {'core'|'deep'|'write'} ToolTier
* @typedef {{ type: 'function', name: string, description: string, parameters: object, tier?: ToolTier }} ToolDef
*/
/** @type {ToolDef[]} */
export const TOOL_DEFS = [
// ── core: diagnosis + navigation ─────────────────────────────────────
{
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 },
},
name: 'investigate_host',
tier: 'core',
description:
'BEST first call for "what\'s wrong" / diagnose / investigate. One shot: findings ranked by severity, KPIs, hot charts, top processes, anomalies/alerts. Prefer this over calling host_snapshot + list_processes + list_anomalies separately.',
parameters: params({
processLimit: { type: 'number', description: 'Top processes (default 12, max 25)' },
hotLimit: { type: 'number', description: 'Hot charts to include (default 12, max 30)' },
}),
},
{
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)' },
name: 'host_snapshot',
tier: 'core',
description:
'Compact live host KPIs (cpu, ram, load, net, io), health, recent anomalies/alerts. Use when you need a lighter snapshot than investigate_host.',
parameters: params({}),
},
{
type: 'function',
name: 'search_charts',
tier: 'core',
description: 'Search the metrics chart catalog by free text (id, title, context, family).',
parameters: params(
{
q: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results (default 20)' },
},
['q']
),
},
{
type: 'function',
name: 'summarize_chart',
tier: 'core',
description: 'Summarize one chart: min/avg/max/last per dimension for a time window.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id e.g. system.cpu' },
after: {
type: 'number',
description: 'Seconds relative (e.g. -300) or absolute unix',
},
required: ['q'],
points: { type: 'number', description: 'Max points (default 90)' },
},
},
['chart']
),
},
{
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'],
name: 'list_anomalies',
tier: 'core',
description: 'List recent anomaly events.',
parameters: params({
limit: { type: 'number', description: 'Max events' },
}),
},
{
type: 'function',
name: 'list_alerts',
tier: 'core',
description: 'List configured/open alerts on the agent.',
parameters: params({}),
},
{
type: 'function',
name: 'list_processes',
tier: 'core',
description: 'Live process table (when agent enables PEARDATA_PROCESSES).',
parameters: params({
sort: {
type: 'string',
description: 'cpu|rss|name',
enum: ['cpu', 'rss', 'name'],
},
},
limit: { type: 'number', description: 'Max rows' },
filter: { type: 'string', description: 'Filter string (all|user|…)' },
}),
},
{
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'],
name: 'local_knowledge',
tier: 'core',
description:
'Search local PearData operator knowledge and chart catalog (no network). Use for product how-to questions.',
parameters: params(
{
q: { type: 'string', description: 'Question or keywords' },
},
},
['q']
),
},
{
type: 'function',
function: {
name: 'list_anomalies',
description: 'List recent anomaly events.',
parameters: {
type: 'object',
properties: { limit: { type: 'number' } },
name: 'open_chart',
tier: 'core',
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
ts: { type: 'number', description: 'Event time ms' },
},
},
['chart']
),
},
{
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' },
name: 'open_view',
tier: 'core',
description:
'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac',
parameters: params(
{
view: {
type: 'string',
description: 'View name',
enum: [
'overview',
'charts',
'processes',
'alerts',
'logs',
'fleet',
'settings',
'qvac',
],
},
},
},
['view']
),
},
// ── deep: investigation, fleet, storage, catalog ───────────────────
{
type: 'function',
name: 'hot_metrics',
tier: 'deep',
description:
'Charts with strongest recent change / anomaly signal. Great investigation starting points when investigate_host is too broad.',
parameters: params({
limit: { type: 'number', description: 'Max charts (default 15, max 50)' },
window: { type: 'number', description: 'Lookback seconds (default 120, 20..600)' },
family: { type: 'string', description: 'Optional family/context filter e.g. disk, docker' },
}),
},
{
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' },
name: 'related_charts',
tier: 'deep',
description:
'Related charts for a seed chart (catalog family/context + optional alert weights). Use after finding a suspicious chart.',
parameters: params(
{
chart: { type: 'string', description: 'Seed chart id' },
limit: { type: 'number', description: 'Max results (default 12)' },
},
['chart']
),
},
{
type: 'function',
name: 'compare_chart_windows',
tier: 'deep',
description:
'Compare two time windows on one chart (highlight vs baseline). Defaults: last 5m vs prior 20m. Returns per-dim relative change.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
after: { type: 'number', description: 'Highlight window start (default -300)' },
before: { type: 'number', description: 'Highlight window end (default 0)' },
baselineAfter: {
type: 'number',
description: 'Baseline window start (default -1500)',
},
baselineBefore: {
type: 'number',
description: 'Baseline window end (default -300)',
},
points: { type: 'number', description: 'Points per window (default 60)' },
},
['chart']
),
},
{
type: 'function',
name: 'summarize_charts',
tier: 'deep',
description: 'Batch summarize up to 12 charts in one call (compact stats).',
parameters: params(
{
charts: {
type: 'string',
description: 'Comma or space separated chart ids (max 12)',
},
after: { type: 'number', description: 'Window start (default -120)' },
points: { type: 'number', description: 'Points per chart (default 60)' },
},
['charts']
),
},
{
type: 'function',
name: 'query_metric',
tier: 'deep',
description: 'Raw queryData time series for a chart (full points). Prefer summarize_chart when stats suffice.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
after: { type: 'number', description: 'Window start (relative or unix)' },
points: { type: 'number', description: 'Max points' },
group: {
type: 'string',
description: 'Aggregation: average|min|max|sum',
enum: ['average', 'min', 'max', 'sum'],
},
},
},
['chart']
),
},
{
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'],
name: 'get_weights',
tier: 'deep',
description:
'Metric correlation / influence weights for a chart (alerts method). Helps find what moves with an incident chart.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
limit: { type: 'number', description: 'Max related (default 20)' },
},
},
['chart']
),
},
{
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'],
name: 'query_logs',
tier: 'deep',
description:
'Query agent logs: source journal|anomaly|audit, optional free-text q. journal/audit may require admin on the agent.',
parameters: params({
source: {
type: 'string',
description: 'journal|anomaly|audit',
enum: ['journal', 'anomaly', 'audit'],
},
},
q: { type: 'string', description: 'Search text' },
limit: { type: 'number', description: 'Max lines' },
}),
},
{
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'],
},
},
name: 'fleet_health',
tier: 'deep',
description: 'Fleet / parent-child health summary when parent mode is enabled.',
parameters: params({}),
},
{
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'],
name: 'list_child_peers',
tier: 'deep',
description: 'List child peers in fleet/parent mode (hostname, key, hops).',
parameters: params({}),
},
{
type: 'function',
name: 'storage_info',
tier: 'deep',
description: 'Agent storage usage and retention config (warm/history sizes).',
parameters: params({}),
},
{
type: 'function',
name: 'agent_health',
tier: 'deep',
description: 'Raw agent health object from anomaly engine (status, warnings, critical).',
parameters: params({}),
},
{
type: 'function',
name: 'node_info',
tier: 'deep',
description: 'Agent node metadata: version, hostname, platform, uptime, collectors.',
parameters: params({}),
},
{
type: 'function',
name: 'db_info',
tier: 'deep',
description: 'HyperDB / replication db info for the agent store.',
parameters: params({}),
},
{
type: 'function',
name: 'list_contexts',
tier: 'deep',
description: 'List metric contexts (families of charts) on the agent.',
parameters: params({}),
},
{
type: 'function',
name: 'get_chart',
tier: 'deep',
description: 'Chart metadata: title, family, units, dimensions (no series points).',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
},
},
['chart']
),
},
{
type: 'function',
name: 'list_jobs',
tier: 'deep',
description: 'List on-demand agent jobs (collectOnce, snapshot, retrainAnomaly, …).',
parameters: params({}),
},
{
type: 'function',
name: 'get_alert',
tier: 'deep',
description: 'Fetch one alert by id.',
parameters: params(
{
id: { type: 'string', description: 'Alert id' },
},
['id']
),
},
// ── write: operator actions (gated by role + confirm) ────────────────
{
type: 'function',
name: 'silence_alert',
tier: 'write',
description:
'Operator only: silence an alert by id for durationMs (requires confirm). Prefer explaining first.',
parameters: params(
{
id: { type: 'string', description: 'Alert id' },
durationMs: {
type: 'number',
description: 'Silence duration ms (default 3600000)',
},
confirmed: {
type: 'boolean',
description: 'Must be true after user confirms',
},
},
['id']
),
},
{
type: 'function',
name: 'ack_alert',
tier: 'write',
description: 'Operator only: acknowledge an alert by id (requires confirm).',
parameters: params(
{
id: { type: 'string', description: 'Alert id' },
confirmed: {
type: 'boolean',
description: 'Must be true after user confirms',
},
},
['id']
),
},
{
type: 'function',
name: 'run_job',
tier: 'write',
description:
'Operator only: run an on-demand job (collectOnce, snapshot, retrainAnomaly, gcBuffers, …). Requires confirm.',
parameters: params(
{
name: {
type: 'string',
description: 'Job name from list_jobs',
},
confirmed: {
type: 'boolean',
description: 'Must be true after user confirms',
},
},
['name']
),
},
]
/** Tools that work without an agent connection. */
const LOCAL_TOOLS = new Set(['open_view', 'open_chart', 'local_knowledge'])
/** Operator write tools. */
const WRITE_TOOLS = new Set(
TOOL_DEFS.filter((t) => t.tier === 'write').map((t) => t.name)
)
/**
* Map profile id → max tool tier depth.
* @param {string} profileId
* @returns {'core'|'deep'}
*/
export function toolDepthForProfile(profileId) {
const id = String(profileId || 'recommended')
if (id === 'lite' || id === 'tool-tiny') return 'core'
return 'deep'
}
/**
* @param {ToolDef} def
* @param {'core'|'deep'} depth
* @param {string} role
*/
function includeTool(def, depth, role) {
const tier = def.tier || 'core'
if (tier === 'write') return roleAllows(role, Roles.operator)
if (tier === 'deep') return depth === 'deep'
return true
}
/**
* Strip internal `tier` before sending schemas to the model.
* @param {ToolDef[]} defs
*/
function wireDefs(defs) {
return defs.map(({ type, name, description, parameters }) => ({
type,
name,
description,
parameters,
}))
}
/**
* @param {{
* manager: { request: (m: string, a?: object) => Promise<any>, active: any },
@@ -211,14 +484,24 @@ export function createToolRunner(deps) {
* @param {object} args
*/
async function run(name, args = {}) {
const localOk = ['open_view', 'open_chart', 'local_knowledge'].includes(name)
if (!deps.isConnected?.() && !localOk) {
if (!LOCAL_TOOLS.has(name) && !deps.isConnected?.()) {
return { error: 'No agent connected. Connect from the Connect tab first.' }
}
if (WRITE_TOOLS.has(name)) {
const role = deps.getRole?.() || Roles.viewer
if (!roleAllows(role, Roles.operator)) {
return { error: 'Operator role required for this action' }
}
}
const req = (m, a) => deps.manager.request(m, a || {})
try {
switch (name) {
case 'investigate_host':
return await req(Methods.investigateHost, {
processLimit: args.processLimit,
hotLimit: args.hotLimit,
})
case 'host_snapshot':
return await req(Methods.getHostSnapshot, {})
case 'search_charts':
@@ -233,6 +516,20 @@ export function createToolRunner(deps) {
points: args.points,
group: args.group,
})
case 'summarize_charts': {
let charts = args.charts
if (typeof charts === 'string') {
charts = charts.split(/[\s,]+/).filter(Boolean)
}
if (!Array.isArray(charts) && args.chart) {
charts = [args.chart]
}
return await req(Methods.summarizeCharts, {
charts,
after: args.after,
points: args.points,
})
}
case 'query_metric':
return await req(Methods.queryData, {
chart: args.chart,
@@ -240,10 +537,39 @@ export function createToolRunner(deps) {
points: args.points ?? 90,
group: args.group || 'average',
})
case 'hot_metrics':
return await req(Methods.hotMetrics, {
limit: args.limit,
window: args.window,
family: args.family || args.q || '',
})
case 'related_charts':
return await req(Methods.relatedCharts, {
chart: args.chart || args.id,
limit: args.limit,
})
case 'compare_chart_windows':
return await req(Methods.compareChartWindows, {
chart: args.chart || args.id,
after: args.after,
before: args.before,
baselineAfter: args.baselineAfter,
baselineBefore: args.baselineBefore,
points: args.points,
group: args.group,
})
case 'get_weights':
return await req(Methods.getWeights, {
chart: args.chart || args.id,
limit: args.limit ?? 20,
method: args.method || 'alerts',
})
case 'list_anomalies':
return await req(Methods.listAnomalies, { limit: args.limit ?? 30 })
case 'list_alerts':
return await req(Methods.listAlerts, {})
case 'get_alert':
return await req(Methods.getAlert, { id: args.id })
case 'list_processes':
return await req(Methods.listProcesses, {
sort: args.sort || 'cpu',
@@ -258,6 +584,8 @@ export function createToolRunner(deps) {
})
case 'fleet_health':
return await req(Methods.getFleetHealth, {})
case 'list_child_peers':
return await req(Methods.listChildPeers, {})
case 'storage_info': {
const [storage, retention] = await Promise.all([
req(Methods.getStorageInfo, {}),
@@ -265,6 +593,20 @@ export function createToolRunner(deps) {
])
return { storage, retention }
}
case 'agent_health':
return await req(Methods.getHealth, {})
case 'node_info':
return await req(Methods.getNodeInfo, {})
case 'db_info':
return await req(Methods.getDbInfo, {})
case 'list_contexts':
return await req(Methods.listContexts, {})
case 'get_chart':
return await req(Methods.getChart, {
id: args.chart || args.id,
})
case 'list_jobs':
return await req(Methods.listJobs, {})
case 'local_knowledge': {
const ctx = buildRagContext({
query: String(args.q || ''),
@@ -282,10 +624,6 @@ export function createToolRunner(deps) {
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',
@@ -302,6 +640,33 @@ export function createToolRunner(deps) {
durationMs: args.durationMs ?? 3_600_000,
})
}
case 'ack_alert': {
if (!args.confirmed) {
return {
error: 'confirmation_required',
message: `Confirm acknowledging alert ${args.id} before retrying with confirmed=true`,
}
}
const ok =
(await deps.confirmAction?.(`Acknowledge alert ${args.id}?`)) !== false
if (!ok) return { error: 'User declined acknowledge' }
return await req(Methods.ackAlert, { id: args.id })
}
case 'run_job': {
if (!args.confirmed) {
return {
error: 'confirmation_required',
message: `Confirm running job "${args.name}" before retrying with confirmed=true`,
}
}
const ok =
(await deps.confirmAction?.(`Run job "${args.name}" on the agent?`)) !== false
if (!ok) return { error: 'User declined run_job' }
return await req(Methods.runJob, {
name: args.name,
args: args.args || {},
})
}
default:
return { error: `Unknown tool: ${name}` }
}
@@ -311,15 +676,18 @@ export function createToolRunner(deps) {
}
/**
* Tools available for the current role.
* Tools available for the current role + profile depth.
* @param {{ profileId?: string, profile?: { id?: string }, depth?: 'core'|'deep' }} [opts]
*/
function defsForRole() {
function defsForRole(opts = {}) {
const role = deps.getRole?.() || Roles.viewer
if (roleAllows(role, Roles.operator)) return TOOL_DEFS
return TOOL_DEFS.filter((t) => t.function.name !== 'silence_alert')
const profileId = opts.profileId || opts.profile?.id || 'recommended'
const depth = opts.depth || toolDepthForProfile(profileId)
const filtered = TOOL_DEFS.filter((t) => includeTool(t, depth, role))
return wireDefs(filtered)
}
return { run, defsForRole, TOOL_DEFS }
return { run, defsForRole, TOOL_DEFS, toolDepthForProfile }
}
/**
@@ -333,7 +701,6 @@ export async function fallbackComplete(userText, tools, opts = {}) {
/** @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') ||
@@ -341,7 +708,21 @@ export async function fallbackComplete(userText, tools, opts = {}) {
q.includes('keyboard') ||
q.includes('retention')
if (!howTo) {
const wantsDiagnose =
!howTo &&
(q.includes("what's wrong") ||
q.includes('whats wrong') ||
q.includes('diagnose') ||
q.includes('investigat') ||
q.includes('summarize host') ||
q.includes('host health') ||
q.includes('what is wrong') ||
(q.includes('health') && !q.includes('md.')))
if (wantsDiagnose) {
const inv = await tools.run('investigate_host', { processLimit: 10, hotLimit: 10 })
calls.push({ name: 'investigate_host', args: {}, result: inv })
} else if (!howTo) {
const snap = await tools.run('host_snapshot', {})
calls.push({ name: 'host_snapshot', args: {}, result: snap })
}
@@ -355,6 +736,10 @@ export async function fallbackComplete(userText, tools, opts = {}) {
const p = await tools.run('list_processes', { limit: 10 })
calls.push({ name: 'list_processes', args: { limit: 10 }, result: p })
}
if (q.includes('hot') || q.includes('spiking') || q.includes('unusual')) {
const h = await tools.run('hot_metrics', { limit: 12 })
calls.push({ name: 'hot_metrics', args: { limit: 12 }, result: h })
}
if (
q.includes('chart') ||
q.includes('metric') ||
@@ -370,6 +755,13 @@ export async function fallbackComplete(userText, tools, opts = {}) {
const s = await tools.run('search_charts', { q: term, limit: 12 })
calls.push({ name: 'search_charts', args: { q: term }, result: s })
}
if (q.includes('related') || q.includes('correlat')) {
const chartMatch = q.match(/\b([a-z][a-z0-9_.-]+\.[a-z0-9_.-]+)\b/)
if (chartMatch) {
const r = await tools.run('related_charts', { chart: chartMatch[1], limit: 10 })
calls.push({ name: 'related_charts', args: { chart: chartMatch[1] }, result: r })
}
}
if (q.includes('anomal') || q.includes('alert')) {
const a = await tools.run('list_anomalies', { limit: 15 })
calls.push({ name: 'list_anomalies', args: {}, result: a })
@@ -399,7 +791,10 @@ export async function fallbackComplete(userText, tools, opts = {}) {
calls.push({ name: 'summarize_chart', args: { chart: 'system.ram' }, result: s })
}
const snap = calls.find((c) => c.name === 'host_snapshot')?.result || null
const snap =
calls.find((c) => c.name === 'investigate_host')?.result ||
calls.find((c) => c.name === 'host_snapshot')?.result ||
null
const text = formatFallbackAnswer(userText, snap, calls, howTo)
return { contentText: text, toolCalls: calls, mode: 'fallback' }
}
@@ -411,30 +806,54 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
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.findings || snap.summary) {
lines.push(`**Investigation** (${snap.hostname || 'agent'})`)
if (snap.summary) {
lines.push(
`- findings=${snap.summary.findingCount ?? 0} top=${snap.summary.topSeverity || 'ok'} health=${snap.summary.health || '—'}`
)
}
}
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 || ''}`)
for (const f of (snap.findings || []).slice(0, 8)) {
lines.push(`- [${f.severity || 'info'}] ${f.area || ''}: ${f.message || ''}`)
}
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.processes?.top?.length) {
lines.push('**Top processes**')
for (const p of snap.processes.top.slice(0, 6)) {
lines.push(`- pid ${p.pid} ${p.name || ''} cpu=${fmt(p.cpu)}`)
}
}
} else {
lines.push('- No recent anomalies in the snapshot.')
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 === 'host_snapshot' || c.name === 'investigate_host') continue
if (c.name === 'local_knowledge' && c.result?.context) {
lines.push(`\n**Local knowledge**\n${c.result.context}`)
}
@@ -444,6 +863,18 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
lines.push(`- \`${r.id}\`${r.title || ''}`)
}
}
if (c.name === 'hot_metrics' && c.result?.results) {
lines.push(`\n**Hot metrics** (${c.result.results.length})`)
for (const r of c.result.results.slice(0, 8)) {
lines.push(`- \`${r.chart}\` score=${fmt(r.score)}${r.reason || ''}`)
}
}
if (c.name === 'related_charts' && c.result?.results) {
lines.push(`\n**Related to ${c.result.chart}**`)
for (const r of c.result.results.slice(0, 8)) {
lines.push(`- \`${r.id}\` (${fmt(r.score)}) ${r.reason || ''}`)
}
}
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)) {
@@ -495,7 +926,9 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
}
if (!lines.length) {
lines.push('No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).')
lines.push(
'No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).'
)
}
lines.push(