Agent Charts
CI / test (push) Successful in 2m37s
Release rolling / release (push) Successful in 11m32s

This commit is contained in:
Raven Scott
2026-07-30 16:28:06 -04:00
parent bcfec67368
commit 6119ba6e46
8 changed files with 734 additions and 20 deletions
+151
View File
@@ -0,0 +1,151 @@
/**
* Inline sparkline embeds for QVAC chat from tool results / agent chart actions.
*/
import { drawChart, seriesColor } from '../charts.js'
/**
* Extract chart ids from tool log for embedding.
* @param {Array<{ name: string, args?: object, result?: any }>} toolLog
* @returns {string[]}
*/
export function chartIdsFromToolLog(toolLog) {
/** @type {string[]} */
const ids = []
const push = (id) => {
const s = String(id || '').trim()
if (s && !ids.includes(s)) ids.push(s)
}
for (const t of toolLog || []) {
const n = t.name || ''
const a = t.args || {}
const r = t.result || {}
if (
n === 'show_charts' ||
n === 'open_chart' ||
n === 'pin_charts' ||
n === 'set_chart_type' ||
n === 'summarize_chart' ||
n === 'query_metric' ||
n === 'compare_chart_windows'
) {
if (a.chart) push(a.chart)
if (a.id) push(a.id)
if (a.charts) {
String(a.charts)
.split(/[\s,]+/)
.filter(Boolean)
.forEach(push)
}
if (Array.isArray(r.charts)) r.charts.forEach(push)
if (r.chart) push(r.chart)
if (r.focus) push(r.focus)
}
if (n === 'hot_metrics' && Array.isArray(r.results)) {
for (const row of r.results.slice(0, 4)) push(row.chart)
}
if (n === 'search_charts' && Array.isArray(r.results)) {
for (const row of r.results.slice(0, 3)) push(row.id)
}
}
return ids.slice(0, 6)
}
/**
* Mount sparklines under a message element.
* @param {HTMLElement} msgEl
* @param {string[]} chartIds
* @param {{
* request: (method: string, args?: object) => Promise<any>,
* catalog?: Record<string, object>,
* onOpen?: (id: string) => void,
* }} deps
*/
export async function mountChartEmbeds(msgEl, chartIds, deps) {
if (!msgEl || !chartIds?.length || !deps?.request) return
let host = msgEl.querySelector('.qvac-chart-embeds')
if (host) host.remove()
host = document.createElement('div')
host.className = 'qvac-chart-embeds'
msgEl.appendChild(host)
for (const id of chartIds) {
const card = document.createElement('button')
card.type = 'button'
card.className = 'qvac-chart-embed'
card.title = `Open ${id} on Charts wall`
const title = deps.catalog?.[id]?.title || id
card.innerHTML = `<header><span class="qvac-chart-embed-id">${escapeHtml(id)}</span><span class="muted">${escapeHtml(title)}</span></header><canvas width="320" height="72"></canvas><footer class="muted">Loading…</footer>`
card.addEventListener('click', () => deps.onOpen?.(id))
host.appendChild(card)
const canvas = card.querySelector('canvas')
const footer = card.querySelector('footer')
try {
const q = await deps.request('queryData', {
chart: id,
after: -180,
points: 90,
group: 'average',
})
const labels = (q.labels || []).filter((l) => l && l !== 'time')
const data = Array.isArray(q.data) ? q.data : []
if (!labels.length || !data.length) {
if (footer) footer.textContent = 'No points'
continue
}
// Prefer up to 3 interesting dims
/** @type {Array<{ values: number[], color: string, label: string }>} */
const lines = []
for (let di = 0; di < Math.min(3, labels.length); di++) {
const vals = data
.map((row) => Number(row[di + 1]))
.filter((v) => Number.isFinite(v))
if (vals.length < 2) continue
// Pad to full length with nulls as 0 for sparkline simplicity
const full = data.map((row) => {
const v = Number(row[di + 1])
return Number.isFinite(v) ? v : 0
})
lines.push({
values: full,
color: seriesColor(di),
label: labels[di],
})
}
if (!lines.length) {
if (footer) footer.textContent = 'No numeric dims'
continue
}
if (canvas) {
drawChart(canvas, lines, {
maxPoints: 90,
showYAxis: false,
padLeft: 4,
mode: 'area',
emptyMessage: '',
})
}
const last = lines[0].values[lines[0].values.length - 1]
if (footer) {
footer.textContent = `${lines.map((l) => l.label).join(', ')} · last ${formatNum(last)}`
}
} catch (err) {
if (footer) footer.textContent = err?.message || 'query failed'
}
}
}
function formatNum(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)
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+26 -1
View File
@@ -11,6 +11,7 @@ import {
runSubAgents,
synthesizeFromAgents,
} from './agents.js'
import { chartIdsFromToolLog, mountChartEmbeds } from './chart-embed.js'
/**
* @param {{
@@ -39,8 +40,9 @@ import {
* saveSettings: (patch: object) => void,
* getPeerLabel: () => string,
* getCatalog?: () => Record<string, object>,
* onOpenChart?: (id: string, ts?: number) => void,
* onOpenChart?: (id: string, ts?: number, opts?: object) => void,
* onOpenView?: (view: string) => void,
* charts?: object,
* log?: (msg: string) => void,
* }} opts
*/
@@ -60,6 +62,7 @@ export function createQvacView(opts) {
getCatalog: () => opts.getCatalog?.() || {},
onOpenChart: opts.onOpenChart,
onOpenView: opts.onOpenView,
charts: opts.charts,
confirmAction: (msg) => {
try {
return typeof confirm === 'function' ? confirm(msg) : true
@@ -892,6 +895,28 @@ export function createQvacView(opts) {
if (chips) chips.remove()
assistantUi.div.appendChild(renderToolChips(toolLog))
}
// Live sparklines under the answer for chart-related tools
const embedIds = chartIdsFromToolLog(toolLog)
if (embedIds.length && assistantUi?.div && opts.manager?.request) {
mountChartEmbeds(assistantUi.div, embedIds, {
request: (m, a) => opts.manager.request(m, a || {}),
catalog: opts.getCatalog?.() || {},
onOpen: (id) => {
opts.onOpenView?.('charts')
if (opts.charts?.showCharts) {
opts.charts.showCharts({
charts: [id],
pin: true,
boardOnly: true,
focus: id,
openFocus: true,
})
} else {
opts.onOpenChart?.(id)
}
},
}).catch(() => {})
}
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
} catch (err) {
const msg = err?.message || String(err)
+16 -9
View File
@@ -28,7 +28,7 @@ export function buildSystemPrompt(ctx = {}) {
'- Cite chart ids (e.g. system.cpu) when discussing metrics.',
'- You are read-only unless the user explicitly asks for an operator action and their role allows it.',
'- Do not claim cloud access; all inference is local via QVAC.',
'- Prefer open_chart / open_view when the user asks to see something in the UI.',
'- Prefer show_charts / open_chart / open_view when the user asks to see graphs or the UI.',
multi
? '- Multi-agent mode: specialist investigators may already have collected a briefing. Trust those facts; only call extra tools for gaps.'
: '',
@@ -36,12 +36,18 @@ export function buildSystemPrompt(ctx = {}) {
'Tool playbook (use these exact tool names and chart ids):',
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
' Lighter alternative: host_snapshot. Do NOT use md.health for general health.',
'- Spikes / unusual activity → hot_metrics, then summarize_chart or compare_chart_windows on top hits.',
'- CPU high / load → investigate_host or host_snapshot + summarize_chart chart=system.cpu + list_processes.',
'- Memory → summarize_chart chart=system.ram (and search_charts q=mem if needed).',
'- Disk / IO / docker / redis / nginx / postgres → search_charts, then summarize_chart on a concrete id.',
'- "Show me a graph/chart/plot" → search_charts if needed, then show_charts with concrete chart ids.',
' show_charts pins to the board, sets window (preset=5m|1h|…), optional mode=line|area|bar|pie, focus.',
' Example: show_charts charts=system.cpu,system.ram preset=15m mode=area boardOnly=true.',
'- Single chart focus → open_chart chart=system.cpu (optional pin, mode, preset, ts).',
'- Pin/unpin board → pin_charts; change type → set_chart_type; window → set_time_window; search wall → filter_charts.',
'- Related wall panel → show_related_ui; Metric Correlations UI → run_correlations.',
'- Spikes / unusual activity → hot_metrics, then show_charts on top hits + summarize_chart.',
'- CPU high / load → investigate_host + show_charts charts=system.cpu,system.load + list_processes.',
'- Memory → show_charts charts=system.ram + summarize_chart chart=system.ram.',
'- Disk / IO / docker / redis / nginx / postgres → search_charts, then show_charts on concrete ids.',
'- Follow-up on one chart → related_charts, get_weights, compare_chart_windows, summarize_charts (batch).',
'- Alerts / anomalies → list_anomalies and/or list_alerts; get_alert for one id.',
'- Alerts / anomalies → list_anomalies and/or list_alerts; get_alert for one id; open_chart with ts on the chart.',
'- Fleet → fleet_health + list_child_peers.',
'- Storage / retention / prune questions → storage_info (+ local_knowledge for product how-to).',
'- Logs → query_logs source=anomaly (or journal/audit when role allows).',
@@ -51,6 +57,7 @@ export function buildSystemPrompt(ctx = {}) {
'- NEVER use chart id "md.health" for general host health — that is MD RAID only and is often empty.',
'- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.',
'- After tools return, give a clear human summary with numbers; do not only restate empty charts.',
'- When you show charts, name the chart ids you opened so the operator can find them.',
'',
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}${multi ? ', multi-agent=on' : ''}.`,
]
@@ -61,12 +68,12 @@ export function buildSystemPrompt(ctx = {}) {
export const SAMPLE_PROMPTS = [
'Summarize host health right now',
"What's wrong — investigate this host",
'Show me live graphs for CPU and RAM (15m)',
'Plot disk / io charts as an area board',
'Why might CPU be high?',
'Show hot / spiking metrics',
'List charts related to disk or io',
'Show hot / spiking metrics and open them',
'Any open alerts or anomalies?',
'Top processes by CPU if available',
'Run a multi-agent investigation of this host',
'How do Charts time presets work?',
'What is retention / warm history?',
]
+290 -7
View File
@@ -129,15 +129,163 @@ export const TOOL_DEFS = [
type: 'function',
name: 'open_chart',
tier: 'core',
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).',
description:
'Open one chart on the Charts wall (scroll + focus). Prefer show_charts when pinning multiple or changing window/type.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
ts: { type: 'number', description: 'Event time ms' },
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',
},
},
['chart']
),
},
{
type: 'function',
name: 'show_charts',
tier: 'core',
description:
'PRIMARY live chart tool. Opens Charts view, pins charts to the board, sets time window/type, optional focus and related panel. Use for "show me graphs", "plot CPU", multi-chart boards.',
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)',
},
},
['charts']
),
},
{
type: 'function',
name: 'pin_charts',
tier: 'core',
description: 'Pin or unpin charts on the Charts board without changing focus.',
parameters: params(
{
charts: {
type: 'string',
description: 'Comma/space separated chart ids',
},
pin: {
type: 'boolean',
description: 'true=pin (default), false=unpin',
},
},
['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'],
},
},
['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' },
}),
},
{
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)' },
},
['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' },
},
['chart']
),
},
{
type: 'function',
name: 'run_correlations',
tier: 'deep',
description:
'Run Metric Correlations around a chart or timestamp and show results 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' },
}),
},
{
type: 'function',
name: 'open_view',
@@ -423,8 +571,19 @@ export const TOOL_DEFS = [
},
]
/** Tools that work without an agent connection. */
const LOCAL_TOOLS = new Set(['open_view', 'open_chart', 'local_knowledge'])
/** 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',
'local_knowledge',
])
/** Operator write tools. */
const WRITE_TOOLS = new Set(
@@ -473,12 +632,32 @@ function wireDefs(defs) {
* getRole: () => string,
* isConnected: () => boolean,
* getCatalog?: () => Record<string, object>,
* onOpenChart?: (chartId: string, ts?: number) => void,
* onOpenChart?: (chartId: string, ts?: number, opts?: object) => void,
* onOpenView?: (view: string) => void,
* 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,
* },
* 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
@@ -494,6 +673,7 @@ export function createToolRunner(deps) {
}
}
const req = (m, a) => deps.manager.request(m, a || {})
const chartsApi = deps.charts || {}
try {
switch (name) {
@@ -616,8 +796,111 @@ export function createToolRunner(deps) {
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 }
const chart = String(args.chart || args.id || '')
if (!chart) return { error: 'chart required' }
deps.onOpenView?.('charts')
if (chartsApi.showCharts) {
return chartsApi.showCharts({
charts: [chart],
pin: args.pin !== false,
boardOnly: args.pin !== false,
focus: chart,
ts: args.ts,
mode: args.mode,
preset: args.preset,
openFocus: true,
})
}
deps.onOpenChart?.(chart, args.ts, {
pin: args.pin !== false,
mode: args.mode,
preset: args.preset,
})
return { ok: true, opened: chart }
}
case 'show_charts': {
const list = parseChartList(args.charts || args.chart || args.ids)
if (!list.length) return { error: 'charts required' }
deps.onOpenView?.('charts')
if (!chartsApi.showCharts) {
// Fallback: open first chart only
deps.onOpenChart?.(list[0], args.ts)
return { ok: true, charts: list, partial: true }
}
return 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: list.length === 1,
})
}
case 'pin_charts': {
const list = parseChartList(args.charts || args.chart)
if (!list.length) return { error: 'charts required' }
deps.onOpenView?.('charts')
if (!chartsApi.setPinned) return { error: 'charts API unavailable' }
return chartsApi.setPinned(list, {
pin: args.pin !== false && args.pin !== 'false',
})
}
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' }
deps.onOpenView?.('charts')
if (!chartsApi.setChartMode) return { error: 'charts API unavailable' }
return chartsApi.setChartMode(chart, mode)
}
case 'set_time_window': {
deps.onOpenView?.('charts')
if (!chartsApi.setTimeWindow) return { error: 'charts API unavailable' }
return chartsApi.setTimeWindow({
preset: args.preset,
seconds: args.seconds ?? args.after,
playing: args.playing,
})
}
case 'filter_charts': {
deps.onOpenView?.('charts')
if (!chartsApi.setFilter) return { error: 'charts API unavailable' }
return chartsApi.setFilter(args.q != null ? args.q : args.filter || '')
}
case 'show_related_ui': {
const chart = String(args.chart || args.id || '')
if (!chart) return { error: 'chart required' }
deps.onOpenView?.('charts')
chartsApi.scrollToChart?.(chart)
await chartsApi.showRelated?.(chart)
return { ok: true, chart, related: true }
}
case 'run_correlations': {
deps.onOpenView?.('charts')
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 }
}
if (chartsApi.runMetricCorrelations) {
const res = await chartsApi.runMetricCorrelations({
chart: args.chart,
ts,
window: args.window,
})
return { ok: true, ...(res || {}) }
}
return { error: 'correlations API unavailable' }
}
case 'open_view': {
deps.onOpenView?.(String(args.view || 'overview'))