1569 lines
52 KiB
JavaScript
1569 lines
52 KiB
JavaScript
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* @typedef {'core'|'deep'|'write'} ToolTier
|
|
* @typedef {{ type: 'function', name: string, description: string, parameters: object, tier?: ToolTier }} ToolDef
|
|
*/
|
|
|
|
/** Shared optional navigate param for UI-affecting tools */
|
|
const NAVIGATE_PROP = {
|
|
navigate: {
|
|
type: 'boolean',
|
|
description:
|
|
'true only if the user asked to open/see that view, or they confirmed auto-navigate. Default false keeps them on the current tab.',
|
|
},
|
|
}
|
|
|
|
/** @type {ToolDef[]} */
|
|
export const TOOL_DEFS = [
|
|
// ── core: diagnosis + navigation ─────────────────────────────────────
|
|
{
|
|
type: 'function',
|
|
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',
|
|
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',
|
|
},
|
|
points: { type: 'number', description: 'Max points (default 90)' },
|
|
},
|
|
['chart']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
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',
|
|
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',
|
|
name: 'open_chart',
|
|
tier: 'core',
|
|
description:
|
|
'Prepare one chart on the Charts wall (pin/focus). Does NOT switch tabs unless navigate=true or user allows auto-navigate.',
|
|
parameters: params(
|
|
{
|
|
chart: { type: 'string', description: 'Chart id' },
|
|
ts: { type: 'number', description: 'Event time ms (pauses near event)' },
|
|
pin: { type: 'boolean', description: 'Pin chart to board (default true)' },
|
|
mode: {
|
|
type: 'string',
|
|
description: 'Chart type: line|area|stacked|bar|multibar|pie',
|
|
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
|
|
},
|
|
preset: {
|
|
type: 'string',
|
|
description: 'Time preset e.g. 5m|15m|1h|6h|24h',
|
|
},
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['chart']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'show_charts',
|
|
tier: 'core',
|
|
description:
|
|
'PRIMARY chart tool: pin charts, set window/type on the Charts wall. Stays on QVAC unless navigate=true (user asked to see graphs) or auto-navigate allows.',
|
|
parameters: params(
|
|
{
|
|
charts: {
|
|
type: 'string',
|
|
description: 'Comma/space separated chart ids (max 24), e.g. system.cpu,system.ram',
|
|
},
|
|
pin: {
|
|
type: 'boolean',
|
|
description: 'Pin charts to board (default true)',
|
|
},
|
|
boardOnly: {
|
|
type: 'boolean',
|
|
description: 'Show only pinned board (default true when pinning)',
|
|
},
|
|
focus: { type: 'string', description: 'Chart id to focus/expand' },
|
|
ts: { type: 'number', description: 'Event time ms for focus' },
|
|
preset: {
|
|
type: 'string',
|
|
description: 'Time preset: 5m|15m|30m|1h|3h|6h|12h|24h',
|
|
},
|
|
seconds: {
|
|
type: 'number',
|
|
description: 'Custom lookback window seconds (alternative to preset)',
|
|
},
|
|
mode: {
|
|
type: 'string',
|
|
description: 'Apply chart type to all listed charts',
|
|
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
|
|
},
|
|
filter: {
|
|
type: 'string',
|
|
description: 'Charts wall search filter text',
|
|
},
|
|
related: {
|
|
type: 'boolean',
|
|
description: 'Open related-charts panel for focus chart',
|
|
},
|
|
playing: {
|
|
type: 'boolean',
|
|
description: 'Live play (true) or pause (false)',
|
|
},
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['charts']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'pin_charts',
|
|
tier: 'core',
|
|
description: 'Pin or unpin charts on the Charts board (no tab switch unless navigate=true).',
|
|
parameters: params(
|
|
{
|
|
charts: {
|
|
type: 'string',
|
|
description: 'Comma/space separated chart ids',
|
|
},
|
|
pin: {
|
|
type: 'boolean',
|
|
description: 'true=pin (default), false=unpin',
|
|
},
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['charts']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'set_chart_type',
|
|
tier: 'core',
|
|
description: 'Change visualization for a chart: line, area, stacked, bar, multibar, pie.',
|
|
parameters: params(
|
|
{
|
|
chart: { type: 'string', description: 'Chart id' },
|
|
mode: {
|
|
type: 'string',
|
|
description: 'line|area|stacked|bar|multibar|pie',
|
|
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
|
|
},
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['chart', 'mode']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'set_time_window',
|
|
tier: 'core',
|
|
description: 'Set Charts wall time window (preset or seconds) and play/pause live.',
|
|
parameters: params({
|
|
preset: {
|
|
type: 'string',
|
|
description: '5m|15m|30m|1h|3h|6h|12h|24h',
|
|
},
|
|
seconds: { type: 'number', description: 'Lookback seconds' },
|
|
playing: { type: 'boolean', description: 'Live updates on/off' },
|
|
...NAVIGATE_PROP,
|
|
}),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'filter_charts',
|
|
tier: 'core',
|
|
description: 'Filter the Charts wall by free text (same as the search box).',
|
|
parameters: params(
|
|
{
|
|
q: { type: 'string', description: 'Filter query (empty clears)' },
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['q']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'show_related_ui',
|
|
tier: 'core',
|
|
description: 'Open the Related charts panel for a seed chart on the Charts wall.',
|
|
parameters: params(
|
|
{
|
|
chart: { type: 'string', description: 'Seed chart id' },
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['chart']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'run_correlations',
|
|
tier: 'deep',
|
|
description:
|
|
'Run Metric Correlations around a chart or timestamp on the Charts wall.',
|
|
parameters: params({
|
|
chart: { type: 'string', description: 'Optional seed chart id' },
|
|
ts: { type: 'number', description: 'Center timestamp ms (default now)' },
|
|
window: { type: 'number', description: 'Highlight window seconds' },
|
|
...NAVIGATE_PROP,
|
|
}),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'open_view',
|
|
tier: 'core',
|
|
description:
|
|
'Switch the user to a desktop view. Only when they asked to go there, or navigate=true / auto-navigate allows. Views: overview|charts|dashboard|processes|alerts|logs|fleet|settings|qvac',
|
|
parameters: params(
|
|
{
|
|
view: {
|
|
type: 'string',
|
|
description: 'View name',
|
|
enum: [
|
|
'overview',
|
|
'charts',
|
|
'dashboard',
|
|
'processes',
|
|
'alerts',
|
|
'logs',
|
|
'fleet',
|
|
'settings',
|
|
'qvac',
|
|
],
|
|
},
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['view']
|
|
),
|
|
},
|
|
|
|
// ── custom dashboards (user boards the agent can build/edit) ──────────
|
|
{
|
|
type: 'function',
|
|
name: 'list_dashboards',
|
|
tier: 'core',
|
|
description: 'List custom dashboards (id, name, chart tiles).',
|
|
parameters: params({}),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'create_dashboard',
|
|
tier: 'core',
|
|
description:
|
|
'Create a custom live dashboard. Pass name + charts. Tab switch only with navigate=true or auto-navigate allow.',
|
|
parameters: params(
|
|
{
|
|
name: { type: 'string', description: 'Dashboard name' },
|
|
description: { type: 'string', description: 'Optional description' },
|
|
charts: {
|
|
type: 'string',
|
|
description: 'Comma/space chart ids e.g. system.cpu,system.ram,system.io',
|
|
},
|
|
mode: {
|
|
type: 'string',
|
|
description: 'Default tile mode line|area|bar|…',
|
|
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
|
|
},
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['name']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'update_dashboard',
|
|
tier: 'core',
|
|
description: 'Rename/update a dashboard (id optional = active). Can replace tiles via charts list.',
|
|
parameters: params({
|
|
id: { type: 'string', description: 'Dashboard id' },
|
|
name: { type: 'string', description: 'New name' },
|
|
description: { type: 'string', description: 'New description' },
|
|
charts: {
|
|
type: 'string',
|
|
description: 'If set, replace all tiles with these chart ids',
|
|
},
|
|
mode: { type: 'string', description: 'Mode when replacing tiles' },
|
|
}),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'delete_dashboard',
|
|
tier: 'core',
|
|
description: 'Delete a custom dashboard by id.',
|
|
parameters: params(
|
|
{
|
|
id: { type: 'string', description: 'Dashboard id' },
|
|
},
|
|
['id']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'add_dashboard_charts',
|
|
tier: 'core',
|
|
description: 'Add chart tiles to a dashboard (id optional = active board).',
|
|
parameters: params(
|
|
{
|
|
id: { type: 'string', description: 'Dashboard id (default active)' },
|
|
charts: {
|
|
type: 'string',
|
|
description: 'Comma/space chart ids to add',
|
|
},
|
|
mode: {
|
|
type: 'string',
|
|
description: 'Tile chart type',
|
|
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
|
|
},
|
|
...NAVIGATE_PROP,
|
|
},
|
|
['charts']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'remove_dashboard_charts',
|
|
tier: 'core',
|
|
description: 'Remove chart tiles from a dashboard.',
|
|
parameters: params(
|
|
{
|
|
id: { type: 'string', description: 'Dashboard id (default active)' },
|
|
charts: {
|
|
type: 'string',
|
|
description: 'Comma/space chart ids to remove',
|
|
},
|
|
},
|
|
['charts']
|
|
),
|
|
},
|
|
{
|
|
type: 'function',
|
|
name: 'open_dashboard',
|
|
tier: 'core',
|
|
description:
|
|
'Select a dashboard board. Switches to Dashboard tab only with navigate=true or auto-navigate allow (user asked to open it).',
|
|
parameters: params({
|
|
id: { type: 'string', description: 'Dashboard id (optional)' },
|
|
...NAVIGATE_PROP,
|
|
}),
|
|
},
|
|
|
|
// ── 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',
|
|
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',
|
|
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',
|
|
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',
|
|
name: 'fleet_health',
|
|
tier: 'deep',
|
|
description: 'Fleet / parent-child health summary when parent mode is enabled.',
|
|
parameters: params({}),
|
|
},
|
|
{
|
|
type: 'function',
|
|
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 (desktop UI / local RAG). */
|
|
const LOCAL_TOOLS = new Set([
|
|
'open_view',
|
|
'open_chart',
|
|
'show_charts',
|
|
'pin_charts',
|
|
'set_chart_type',
|
|
'set_time_window',
|
|
'filter_charts',
|
|
'show_related_ui',
|
|
'run_correlations',
|
|
'list_dashboards',
|
|
'create_dashboard',
|
|
'update_dashboard',
|
|
'delete_dashboard',
|
|
'add_dashboard_charts',
|
|
'remove_dashboard_charts',
|
|
'open_dashboard',
|
|
'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,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Resolve auto-navigate preference: off | ask | on
|
|
* @param {*} deps
|
|
*/
|
|
function autoNavMode(deps) {
|
|
const raw = deps.getAutoNavigate?.() ?? deps.autoNavigate ?? 'ask'
|
|
const s = String(raw || 'ask').toLowerCase()
|
|
if (s === 'on' || s === 'true' || s === 'always') return 'on'
|
|
if (s === 'off' || s === 'false' || s === 'never') return 'off'
|
|
return 'ask'
|
|
}
|
|
|
|
/**
|
|
* Switch desktop view only when allowed.
|
|
* @param {string} view
|
|
* @param {*} deps
|
|
* @param {object} [args]
|
|
* @returns {Promise<{ navigated: boolean, reason: string }>}
|
|
*/
|
|
async function maybeNavigate(view, deps, args = {}) {
|
|
const force =
|
|
args.navigate === true ||
|
|
args.navigate === 'true' ||
|
|
args.navigate === 1 ||
|
|
args.navigate === '1'
|
|
const mode = autoNavMode(deps)
|
|
const label = String(view || 'that view')
|
|
|
|
if (force || mode === 'on') {
|
|
deps.onOpenView?.(view)
|
|
return { navigated: true, reason: force ? 'navigate=true' : 'auto_on' }
|
|
}
|
|
if (mode === 'ask') {
|
|
const ok =
|
|
(await deps.confirmAction?.(
|
|
`QVAC wants to open the “${label}” view. Switch now?`
|
|
)) === true
|
|
if (ok) {
|
|
deps.onOpenView?.(view)
|
|
return { navigated: true, reason: 'user_confirmed' }
|
|
}
|
|
return { navigated: false, reason: 'user_declined' }
|
|
}
|
|
// off
|
|
return { navigated: false, reason: 'auto_off' }
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* manager: { request: (m: string, a?: object) => Promise<any>, active: any },
|
|
* getRole: () => string,
|
|
* isConnected: () => boolean,
|
|
* getCatalog?: () => Record<string, object>,
|
|
* onOpenChart?: (chartId: string, ts?: number, opts?: object) => void,
|
|
* onOpenView?: (view: string) => void,
|
|
* getAutoNavigate?: () => 'off'|'ask'|'on'|string,
|
|
* autoNavigate?: 'off'|'ask'|'on'|string,
|
|
* charts?: {
|
|
* showCharts?: (opts: object) => object,
|
|
* setPinned?: (ids: string|string[], opts?: object) => object,
|
|
* setChartMode?: (id: string, mode: string) => object,
|
|
* setTimeWindow?: (opts: object) => object,
|
|
* setFilter?: (q: string) => object,
|
|
* showRelated?: (id: string) => Promise<void>|void,
|
|
* openFocus?: (id: string) => void,
|
|
* correlateAround?: (ts: number, opts?: object) => void,
|
|
* runMetricCorrelations?: (opts?: object) => Promise<any>,
|
|
* scrollToChart?: (id: string) => void,
|
|
* },
|
|
* dashboards?: {
|
|
* list?: () => object,
|
|
* create?: (a: object) => object,
|
|
* update?: (a: object) => object,
|
|
* remove?: (a: object) => object,
|
|
* addCharts?: (a: object) => object,
|
|
* removeCharts?: (a: object) => object,
|
|
* open?: (a: object) => object,
|
|
* },
|
|
* confirmAction?: (message: string) => boolean|Promise<boolean>,
|
|
* }} deps
|
|
*/
|
|
export function createToolRunner(deps) {
|
|
function parseChartList(raw) {
|
|
if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
|
|
return String(raw || '')
|
|
.split(/[\s,]+/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @param {object} args
|
|
*/
|
|
async function run(name, args = {}) {
|
|
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 || {})
|
|
const chartsApi = deps.charts || {}
|
|
|
|
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':
|
|
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 '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,
|
|
after: args.after ?? -90,
|
|
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',
|
|
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 'list_child_peers':
|
|
return await req(Methods.listChildPeers, {})
|
|
case 'storage_info': {
|
|
const [storage, retention] = await Promise.all([
|
|
req(Methods.getStorageInfo, {}),
|
|
req(Methods.getRetentionConfig, {}),
|
|
])
|
|
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 || ''),
|
|
catalog: deps.getCatalog?.() || {},
|
|
topK: 8,
|
|
})
|
|
return { context: ctx || 'No matching local knowledge.', q: args.q }
|
|
}
|
|
case 'open_chart': {
|
|
const chart = String(args.chart || args.id || '')
|
|
if (!chart) return { error: 'chart required' }
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
let result
|
|
if (chartsApi.showCharts) {
|
|
result = chartsApi.showCharts({
|
|
charts: [chart],
|
|
pin: args.pin !== false,
|
|
boardOnly: args.pin !== false,
|
|
focus: chart,
|
|
ts: args.ts,
|
|
mode: args.mode,
|
|
preset: args.preset,
|
|
openFocus: nav.navigated,
|
|
})
|
|
} else {
|
|
deps.onOpenChart?.(chart, args.ts, {
|
|
pin: args.pin !== false,
|
|
mode: args.mode,
|
|
preset: args.preset,
|
|
navigate: nav.navigated,
|
|
})
|
|
result = { ok: true, opened: chart }
|
|
}
|
|
return { ...result, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'show_charts': {
|
|
const list = parseChartList(args.charts || args.chart || args.ids)
|
|
if (!list.length) return { error: 'charts required' }
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
if (!chartsApi.showCharts) {
|
|
if (nav.navigated) deps.onOpenChart?.(list[0], args.ts)
|
|
return {
|
|
ok: true,
|
|
charts: list,
|
|
partial: true,
|
|
navigated: nav.navigated,
|
|
navigateReason: nav.reason,
|
|
}
|
|
}
|
|
const result = chartsApi.showCharts({
|
|
charts: list,
|
|
pin: args.pin !== false,
|
|
boardOnly: args.boardOnly != null ? Boolean(args.boardOnly) : args.pin !== false,
|
|
focus: args.focus || list[0],
|
|
ts: args.ts,
|
|
preset: args.preset,
|
|
seconds: args.seconds,
|
|
mode: args.mode,
|
|
filter: args.filter,
|
|
related: Boolean(args.related),
|
|
playing: args.playing,
|
|
openFocus: nav.navigated && list.length === 1,
|
|
})
|
|
return { ...result, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'pin_charts': {
|
|
const list = parseChartList(args.charts || args.chart)
|
|
if (!list.length) return { error: 'charts required' }
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
if (!chartsApi.setPinned) return { error: 'charts API unavailable' }
|
|
const result = chartsApi.setPinned(list, {
|
|
pin: args.pin !== false && args.pin !== 'false',
|
|
})
|
|
return { ...result, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'set_chart_type': {
|
|
const chart = String(args.chart || args.id || '')
|
|
const mode = String(args.mode || args.type || 'line')
|
|
if (!chart) return { error: 'chart required' }
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
if (!chartsApi.setChartMode) return { error: 'charts API unavailable' }
|
|
const result = chartsApi.setChartMode(chart, mode)
|
|
return { ...result, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'set_time_window': {
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
if (!chartsApi.setTimeWindow) return { error: 'charts API unavailable' }
|
|
const result = chartsApi.setTimeWindow({
|
|
preset: args.preset,
|
|
seconds: args.seconds ?? args.after,
|
|
playing: args.playing,
|
|
})
|
|
return { ...result, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'filter_charts': {
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
if (!chartsApi.setFilter) return { error: 'charts API unavailable' }
|
|
const result = chartsApi.setFilter(args.q != null ? args.q : args.filter || '')
|
|
return { ...result, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'show_related_ui': {
|
|
const chart = String(args.chart || args.id || '')
|
|
if (!chart) return { error: 'chart required' }
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
chartsApi.scrollToChart?.(chart)
|
|
await chartsApi.showRelated?.(chart)
|
|
return {
|
|
ok: true,
|
|
chart,
|
|
related: true,
|
|
navigated: nav.navigated,
|
|
navigateReason: nav.reason,
|
|
}
|
|
}
|
|
case 'run_correlations': {
|
|
const nav = await maybeNavigate('charts', deps, args)
|
|
const ts = args.ts != null ? Number(args.ts) : Date.now()
|
|
if (chartsApi.correlateAround) {
|
|
chartsApi.correlateAround(ts, {
|
|
chart: args.chart,
|
|
window: args.window,
|
|
})
|
|
return {
|
|
ok: true,
|
|
ts,
|
|
chart: args.chart || null,
|
|
navigated: nav.navigated,
|
|
navigateReason: nav.reason,
|
|
}
|
|
}
|
|
if (chartsApi.runMetricCorrelations) {
|
|
const res = await chartsApi.runMetricCorrelations({
|
|
chart: args.chart,
|
|
ts,
|
|
window: args.window,
|
|
})
|
|
return {
|
|
ok: true,
|
|
...(res || {}),
|
|
navigated: nav.navigated,
|
|
navigateReason: nav.reason,
|
|
}
|
|
}
|
|
return { error: 'correlations API unavailable' }
|
|
}
|
|
case 'open_view': {
|
|
// Explicit navigation tool — still gated by auto-nav / navigate flag
|
|
const view = String(args.view || 'overview')
|
|
const nav = await maybeNavigate(view, deps, {
|
|
...args,
|
|
// open_view implies intent to navigate when auto is on/ask;
|
|
// with auto off, still require navigate=true
|
|
navigate: args.navigate === true || args.navigate === 'true' ? true : args.navigate,
|
|
})
|
|
// When mode is ask/on, maybeNavigate already handled. When off without force, no-op.
|
|
if (!nav.navigated && autoNavMode(deps) !== 'off') {
|
|
// ask declined already returned; on should have navigated
|
|
}
|
|
// For open_view with auto on, force path already navigated.
|
|
// With auto off, require navigate=true (maybeNavigate handles).
|
|
// With auto ask without prior force: maybeNavigate already asked.
|
|
// Special case: if auto is off and navigate not set, try one confirm as explicit open_view
|
|
if (!nav.navigated && autoNavMode(deps) === 'off' && args.navigate == null) {
|
|
const ok =
|
|
(await deps.confirmAction?.(
|
|
`Switch to the “${view}” view? (You can enable Auto-navigate in QVAC Settings.)`
|
|
)) === true
|
|
if (ok) {
|
|
deps.onOpenView?.(view)
|
|
return { ok: true, view, navigated: true, navigateReason: 'user_confirmed' }
|
|
}
|
|
return {
|
|
ok: true,
|
|
view,
|
|
navigated: false,
|
|
navigateReason: 'user_declined',
|
|
note: 'Stayed on current tab. Pass navigate=true when the user asks to open a view.',
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
view,
|
|
navigated: nav.navigated,
|
|
navigateReason: nav.reason,
|
|
note: nav.navigated
|
|
? undefined
|
|
: 'View not switched. User did not allow navigation.',
|
|
}
|
|
}
|
|
case 'list_dashboards': {
|
|
if (!deps.dashboards?.list) return { error: 'dashboards API unavailable' }
|
|
return deps.dashboards.list()
|
|
}
|
|
case 'create_dashboard': {
|
|
if (!deps.dashboards?.create) return { error: 'dashboards API unavailable' }
|
|
const r = deps.dashboards.create({
|
|
name: args.name,
|
|
description: args.description,
|
|
charts: args.charts || args.chart,
|
|
mode: args.mode || 'area',
|
|
tiles: args.tiles,
|
|
})
|
|
const nav = await maybeNavigate('dashboard', deps, args)
|
|
return { ...r, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'update_dashboard': {
|
|
if (!deps.dashboards?.update) return { error: 'dashboards API unavailable' }
|
|
return deps.dashboards.update({
|
|
id: args.id || args.dashboardId,
|
|
name: args.name,
|
|
description: args.description,
|
|
charts: args.charts,
|
|
mode: args.mode,
|
|
tiles: args.tiles,
|
|
})
|
|
}
|
|
case 'delete_dashboard': {
|
|
if (!deps.dashboards?.remove) return { error: 'dashboards API unavailable' }
|
|
return deps.dashboards.remove({ id: args.id || args.dashboardId })
|
|
}
|
|
case 'add_dashboard_charts': {
|
|
if (!deps.dashboards?.addCharts) return { error: 'dashboards API unavailable' }
|
|
const r = deps.dashboards.addCharts({
|
|
id: args.id || args.dashboardId,
|
|
charts: args.charts || args.chart,
|
|
mode: args.mode,
|
|
replace: args.replace,
|
|
})
|
|
const nav = await maybeNavigate('dashboard', deps, args)
|
|
return { ...r, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'remove_dashboard_charts': {
|
|
if (!deps.dashboards?.removeCharts) return { error: 'dashboards API unavailable' }
|
|
return deps.dashboards.removeCharts({
|
|
id: args.id || args.dashboardId,
|
|
charts: args.charts || args.chart,
|
|
})
|
|
}
|
|
case 'open_dashboard': {
|
|
if (!deps.dashboards?.open) {
|
|
const nav = await maybeNavigate('dashboard', deps, {
|
|
...args,
|
|
navigate: args.navigate != null ? args.navigate : true,
|
|
})
|
|
return {
|
|
ok: true,
|
|
view: 'dashboard',
|
|
navigated: nav.navigated,
|
|
navigateReason: nav.reason,
|
|
}
|
|
}
|
|
// Select board first, then maybe switch tab
|
|
const r = deps.dashboards.open({
|
|
id: args.id || args.dashboardId,
|
|
navigate: false,
|
|
})
|
|
const nav = await maybeNavigate('dashboard', deps, {
|
|
...args,
|
|
// open_dashboard is an open request: default navigate ask/on, but still confirm
|
|
navigate: args.navigate != null ? args.navigate : undefined,
|
|
})
|
|
// If open always showed view before, prevent double — dashboards.open in app still navigates
|
|
return { ...r, navigated: nav.navigated, navigateReason: nav.reason }
|
|
}
|
|
case 'silence_alert': {
|
|
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,
|
|
})
|
|
}
|
|
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}` }
|
|
}
|
|
} catch (err) {
|
|
return { error: err?.message || String(err) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tools available for the current role + profile depth.
|
|
* @param {{ profileId?: string, profile?: { id?: string }, depth?: 'core'|'deep' }} [opts]
|
|
*/
|
|
function defsForRole(opts = {}) {
|
|
const role = deps.getRole?.() || Roles.viewer
|
|
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, toolDepthForProfile }
|
|
}
|
|
|
|
/**
|
|
* 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 = []
|
|
|
|
const howTo =
|
|
q.includes('how do') ||
|
|
q.includes('what is qvac') ||
|
|
q.includes('how to') ||
|
|
q.includes('keyboard') ||
|
|
q.includes('retention')
|
|
|
|
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 })
|
|
}
|
|
|
|
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('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') ||
|
|
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('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 })
|
|
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 === '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' }
|
|
}
|
|
|
|
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) {
|
|
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 || '—'}`
|
|
)
|
|
}
|
|
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(`**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' || c.name === 'investigate_host') 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 === '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)) {
|
|
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)
|
|
}
|