809 lines
25 KiB
JavaScript
809 lines
25 KiB
JavaScript
/**
|
|
* QVAC engine facade — load models, stream completion, tool loop.
|
|
* Uses @qvac/sdk when available; otherwise tools-only fallback.
|
|
*/
|
|
import { createRequire } from 'module'
|
|
import { existsSync } from 'fs'
|
|
import { join, dirname } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import { getProfile } from './profiles.js'
|
|
import { buildSystemPrompt } from './prompts.js'
|
|
import { fallbackComplete } from './tools.js'
|
|
import { buildRagContext } from './rag.js'
|
|
import {
|
|
compactMessages,
|
|
estimateToolsTokens,
|
|
isContextOverflowError,
|
|
promptBudget,
|
|
} from './context.js'
|
|
|
|
/** Resolve paths without evaluating package main (safe during onboarding). */
|
|
function resolveSdkPackagePath() {
|
|
// eslint-disable-next-line no-undef
|
|
if (typeof require === 'function') {
|
|
try {
|
|
return require.resolve('@qvac/sdk')
|
|
} catch {
|
|
// continue
|
|
}
|
|
}
|
|
try {
|
|
let base
|
|
try {
|
|
base = fileURLToPath(import.meta.url)
|
|
} catch {
|
|
base = join(process.cwd(), 'package.json')
|
|
}
|
|
const req = createRequire(base)
|
|
return req.resolve('@qvac/sdk')
|
|
} catch {
|
|
// fall through to disk probe
|
|
}
|
|
const candidates = [
|
|
join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'package.json'),
|
|
join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'dist', 'index.js'),
|
|
]
|
|
try {
|
|
// eslint-disable-next-line no-undef
|
|
if (typeof __dirname !== 'undefined') {
|
|
candidates.push(join(__dirname, '..', 'node_modules', '@qvac', 'sdk', 'package.json'))
|
|
candidates.push(join(__dirname, '..', '..', 'node_modules', '@qvac', 'sdk', 'package.json'))
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
// Packaged Electron: Resources/app/node_modules
|
|
// eslint-disable-next-line no-undef
|
|
if (typeof process !== 'undefined' && process.resourcesPath) {
|
|
candidates.push(
|
|
join(process.resourcesPath, 'app', 'node_modules', '@qvac', 'sdk', 'package.json')
|
|
)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
for (const p of candidates) {
|
|
if (existsSync(p)) return p
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* tools: { run: Function, defsForRole: () => any[] },
|
|
* getContext: () => object,
|
|
* getCatalog?: () => Record<string, object>,
|
|
* getPrefs?: () => { rag?: boolean, idleUnloadMin?: number },
|
|
* log?: (msg: string) => void,
|
|
* }} deps
|
|
*/
|
|
/**
|
|
* Electron renderer? Never require('@qvac/sdk') here — use main-process IPC.
|
|
*/
|
|
function getElectronIpc() {
|
|
try {
|
|
// eslint-disable-next-line no-undef
|
|
if (typeof process !== 'undefined' && process.type === 'renderer') {
|
|
// eslint-disable-next-line no-undef
|
|
return typeof require === 'function' ? require('electron').ipcRenderer : null
|
|
}
|
|
} catch {
|
|
// not electron
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function createQvacEngine(deps) {
|
|
/** @type {any} */
|
|
let sdk = null
|
|
/** @type {'direct'|'main'|null} */
|
|
let sdkMode = null
|
|
let sdkError = null
|
|
/** @type {string|null} */
|
|
let modelId = null
|
|
/** @type {string|null} */
|
|
let profileId = null
|
|
/** @type {'idle'|'checking'|'downloading'|'loading'|'ready'|'error'|'fallback'} */
|
|
let status = 'idle'
|
|
let lastProgress = null
|
|
/** @type {AbortController|null} */
|
|
let loadAbort = null
|
|
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
let idleTimer = null
|
|
let lastActivity = Date.now()
|
|
const ipc = getElectronIpc()
|
|
|
|
function touchActivity() {
|
|
lastActivity = Date.now()
|
|
scheduleIdleUnload()
|
|
}
|
|
|
|
function scheduleIdleUnload() {
|
|
if (idleTimer) clearTimeout(idleTimer)
|
|
idleTimer = null
|
|
const mins = Number(deps.getPrefs?.()?.idleUnloadMin)
|
|
if (!mins || mins <= 0 || !modelId) return
|
|
idleTimer = setTimeout(
|
|
() => {
|
|
if (Date.now() - lastActivity >= mins * 60_000 && modelId) {
|
|
deps.log?.(`QVAC idle unload after ${mins}m`)
|
|
unload().catch(() => {})
|
|
}
|
|
},
|
|
mins * 60_000 + 500
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Lightweight "is the package on disk?" check — never evaluates @qvac/sdk.
|
|
* @returns {{ installed: boolean, error: string|null }}
|
|
*/
|
|
function detectSdkInstalled() {
|
|
if (sdk || modelId) return { installed: true, error: null }
|
|
if (ipc) {
|
|
// Sync detect via invoke is async; use path resolve for UI, main confirms on load
|
|
const path = resolveSdkPackagePath()
|
|
if (path) return { installed: true, error: null }
|
|
// Packaged app may resolve from Resources/app — still try disk
|
|
return { installed: Boolean(resolveSdkPackagePath()), error: null }
|
|
}
|
|
const path = resolveSdkPackagePath()
|
|
if (path) return { installed: true, error: null }
|
|
return { installed: false, error: '@qvac/sdk not installed in this app' }
|
|
}
|
|
|
|
/**
|
|
* Ensure we can talk to QVAC (main IPC in Electron, direct require only outside renderer).
|
|
* @param {number} [timeoutMs]
|
|
*/
|
|
async function tryLoadSdk(timeoutMs = 8_000) {
|
|
if (sdk || (sdkMode === 'main' && modelId)) {
|
|
return sdk || { __viaMain: true }
|
|
}
|
|
if (ipc) {
|
|
try {
|
|
const det = await ipc.invoke('peardata:qvac-detect')
|
|
if (!det?.installed) {
|
|
sdkError = det?.error || '@qvac/sdk not installed'
|
|
return null
|
|
}
|
|
sdkMode = 'main'
|
|
sdk = { __viaMain: true }
|
|
sdkError = null
|
|
deps.log?.('QVAC: using main-process SDK host (safe for Electron UI)')
|
|
return sdk
|
|
} catch (err) {
|
|
sdkError = err?.message || String(err)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Non-Electron (e.g. pear / node): load SDK in-process
|
|
try {
|
|
const load = (async () => {
|
|
try {
|
|
// eslint-disable-next-line no-undef
|
|
if (typeof require === 'function') return require('@qvac/sdk')
|
|
} catch {
|
|
// fall through
|
|
}
|
|
const { createRequire: cr } = await import('module')
|
|
const req = cr(
|
|
typeof __filename !== 'undefined' ? __filename : `${process.cwd()}/package.json`
|
|
)
|
|
return req('@qvac/sdk')
|
|
})()
|
|
const timed =
|
|
timeoutMs > 0
|
|
? Promise.race([
|
|
load,
|
|
new Promise((_, reject) => {
|
|
setTimeout(
|
|
() => reject(new Error(`@qvac/sdk import timed out after ${timeoutMs}ms`)),
|
|
timeoutMs
|
|
)
|
|
}),
|
|
])
|
|
: load
|
|
sdk = await timed
|
|
sdkMode = 'direct'
|
|
sdkError = null
|
|
return sdk
|
|
} catch (err) {
|
|
sdkError = err?.message || String(err)
|
|
sdk = null
|
|
sdkMode = null
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getStatus() {
|
|
const disk = detectSdkInstalled()
|
|
const loaded = Boolean(modelId) && (sdkMode === 'main' || Boolean(sdk))
|
|
return {
|
|
status,
|
|
modelId,
|
|
profileId,
|
|
sdkAvailable: loaded || disk.installed || sdkMode === 'main',
|
|
sdkLoaded: loaded,
|
|
sdkMode,
|
|
sdkError: sdkError || (!disk.installed && !loaded ? disk.error : null),
|
|
progress: lastProgress,
|
|
mode: loaded && status === 'ready' ? 'qvac' : status === 'fallback' || !loaded ? 'fallback' : status,
|
|
lastActivity,
|
|
}
|
|
}
|
|
|
|
function hostPlatformInfo() {
|
|
const mem =
|
|
typeof performance !== 'undefined' && performance.memory
|
|
? performance.memory.jsHeapSizeLimit
|
|
: null
|
|
let totalRamBytes = null
|
|
let freeRamBytes = null
|
|
try {
|
|
// eslint-disable-next-line no-undef
|
|
const osMod = typeof require === 'function' ? require('os') : null
|
|
if (osMod) {
|
|
totalRamBytes = osMod.totalmem?.()
|
|
freeRamBytes = osMod.freemem?.()
|
|
}
|
|
} catch {
|
|
// ESM-only
|
|
}
|
|
return {
|
|
totalRamBytes: totalRamBytes ?? mem,
|
|
freeRamBytes,
|
|
platform: typeof process !== 'undefined' ? process.platform : 'unknown',
|
|
arch: typeof process !== 'undefined' ? process.arch : 'unknown',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lightweight host check for onboarding.
|
|
* Default: NEVER import @qvac/sdk (probeSdk must be explicitly true).
|
|
* @param {{ probeSdk?: boolean, timeoutMs?: number }} [opts]
|
|
*/
|
|
async function checkEnvironment(opts = {}) {
|
|
status = 'checking'
|
|
const host = hostPlatformInfo()
|
|
// Optional async os.totalmem for pure ESM only when sync path missed
|
|
if (host.totalRamBytes == null) {
|
|
try {
|
|
const os = await Promise.race([
|
|
import('os'),
|
|
new Promise((_, rej) => setTimeout(() => rej(new Error('os import timeout')), 1500)),
|
|
])
|
|
host.totalRamBytes = os.totalmem?.() ?? host.totalRamBytes
|
|
host.freeRamBytes = os.freemem?.() ?? host.freeRamBytes
|
|
} catch {
|
|
// keep nulls
|
|
}
|
|
}
|
|
|
|
let sdkAvailable = Boolean(sdk)
|
|
let err = sdkError
|
|
if (opts.probeSdk === true && !sdk) {
|
|
// Explicit full load — only for model download path, not Continue
|
|
const s = await tryLoadSdk(opts.timeoutMs ?? 8000)
|
|
sdkAvailable = Boolean(s)
|
|
err = sdkError
|
|
} else {
|
|
const disk = detectSdkInstalled()
|
|
sdkAvailable = disk.installed || Boolean(sdk)
|
|
if (!disk.installed && !sdk) err = disk.error
|
|
}
|
|
|
|
status = 'idle'
|
|
return {
|
|
sdkAvailable,
|
|
sdkError: err,
|
|
totalRamBytes: host.totalRamBytes,
|
|
freeRamBytes: host.freeRamBytes,
|
|
resources: null,
|
|
platform: host.platform,
|
|
arch: host.arch,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} profile
|
|
* @param {{ onProgress?: (p: any) => void }} [opts]
|
|
*/
|
|
async function loadProfile(profile, opts = {}) {
|
|
const p = getProfile(profile)
|
|
profileId = p.id
|
|
touchActivity()
|
|
status = 'downloading'
|
|
lastProgress = { percentage: 0 }
|
|
|
|
// Electron: main-process host (never require SDK in renderer)
|
|
if (ipc) {
|
|
/** @type {(( _e: any, prog: any) => void)|null} */
|
|
let onProg = null
|
|
try {
|
|
const bridge = await tryLoadSdk()
|
|
if (!bridge) {
|
|
status = 'fallback'
|
|
return { ok: true, mode: 'fallback', profile: p.id, error: sdkError }
|
|
}
|
|
onProg = (_e, prog) => {
|
|
lastProgress = prog
|
|
status = prog?.percentage >= 100 ? 'loading' : 'downloading'
|
|
opts.onProgress?.(prog)
|
|
}
|
|
ipc.on('peardata:qvac-progress', onProg)
|
|
const result = await ipc.invoke('peardata:qvac-load', {
|
|
chatModel: p.chatModel,
|
|
tools: p.tools,
|
|
ctxSize: p.ctxSize || 8192,
|
|
})
|
|
if (!result?.ok) {
|
|
status = 'fallback'
|
|
const msg = result?.error || 'load failed'
|
|
sdkError = msg
|
|
deps.log?.(`QVAC main load failed: ${msg}`)
|
|
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
|
|
}
|
|
modelId = result.modelId
|
|
status = 'ready'
|
|
touchActivity()
|
|
deps.log?.(`QVAC model ready (main): ${p.chatModel} → ${modelId}`)
|
|
return { ok: true, mode: 'qvac', modelId, profile: p.id }
|
|
} catch (err) {
|
|
const msg = err?.message || String(err)
|
|
status = 'fallback'
|
|
sdkError = msg
|
|
deps.log?.(`QVAC load failed: ${msg}`)
|
|
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
|
|
} finally {
|
|
if (onProg) {
|
|
try {
|
|
ipc.removeListener('peardata:qvac-progress', onProg)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const s = await tryLoadSdk()
|
|
if (!s || s.__viaMain) {
|
|
status = 'fallback'
|
|
deps.log?.(`QVAC SDK unavailable (${sdkError}); using tools-only fallback`)
|
|
return { ok: true, mode: 'fallback', profile: p.id }
|
|
}
|
|
|
|
const modelSrc = s[p.chatModel] || p.chatModel
|
|
loadAbort = new AbortController()
|
|
|
|
try {
|
|
if (modelId) await unload()
|
|
|
|
const id = await s.loadModel({
|
|
modelSrc,
|
|
modelType: 'llm',
|
|
modelConfig: {
|
|
tools: p.tools,
|
|
ctx_size: p.ctxSize || 8192,
|
|
},
|
|
onProgress: (prog) => {
|
|
lastProgress = prog
|
|
status = prog?.percentage >= 100 ? 'loading' : 'downloading'
|
|
opts.onProgress?.(prog)
|
|
},
|
|
})
|
|
modelId = id
|
|
status = 'ready'
|
|
touchActivity()
|
|
deps.log?.(`QVAC model ready: ${p.chatModel} → ${id}`)
|
|
return { ok: true, mode: 'qvac', modelId: id, profile: p.id }
|
|
} catch (err) {
|
|
status = 'error'
|
|
const msg = err?.message || String(err)
|
|
deps.log?.(`QVAC load failed: ${msg}`)
|
|
status = 'fallback'
|
|
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
|
|
} finally {
|
|
loadAbort = null
|
|
}
|
|
}
|
|
|
|
async function unload() {
|
|
if (idleTimer) {
|
|
clearTimeout(idleTimer)
|
|
idleTimer = null
|
|
}
|
|
if (ipc && sdkMode === 'main') {
|
|
try {
|
|
await ipc.invoke('peardata:qvac-unload')
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} else if (sdk && modelId && sdk.unloadModel) {
|
|
try {
|
|
await sdk.unloadModel({ modelId })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
modelId = null
|
|
if (status === 'ready' || status === 'loading' || status === 'downloading') status = 'idle'
|
|
}
|
|
|
|
/**
|
|
* @param {Array<{ role: string, content: string }>} history
|
|
* @param {{
|
|
* onToken?: (t: string) => void,
|
|
* onEvent?: (e: any) => void,
|
|
* onTool?: (name: string, args: object, result: any) => void,
|
|
* }} [opts]
|
|
*/
|
|
async function complete(history, opts = {}) {
|
|
touchActivity()
|
|
const profile = getProfile(profileId || 'recommended')
|
|
const prefs = deps.getPrefs?.() || {}
|
|
const userLast = [...history].reverse().find((m) => m.role === 'user')
|
|
let system = buildSystemPrompt(deps.getContext?.() || {})
|
|
|
|
// Light RAG only — full catalog dumps blow small context windows
|
|
if (prefs.rag !== false && userLast?.content) {
|
|
const rag = buildRagContext({
|
|
query: userLast.content,
|
|
catalog: deps.getCatalog?.() || {},
|
|
topK: 3,
|
|
})
|
|
if (rag) system += `\n\n${rag.slice(0, 1200)}`
|
|
}
|
|
|
|
const fullHistory = [
|
|
{ role: 'system', content: system },
|
|
...history.filter((m) => m.role !== 'system'),
|
|
]
|
|
|
|
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
|
|
return completeWithSdk(fullHistory, profile, opts)
|
|
}
|
|
|
|
status = status === 'ready' ? status : 'fallback'
|
|
const result = await fallbackComplete(userLast?.content || '', deps.tools, {
|
|
catalog: deps.getCatalog?.() || {},
|
|
rag: prefs.rag !== false,
|
|
})
|
|
for (const c of result.toolCalls || []) {
|
|
opts.onTool?.(c.name, c.args, c.result)
|
|
}
|
|
if (opts.onToken) {
|
|
const text = result.contentText || ''
|
|
const chunk = 24
|
|
for (let i = 0; i < text.length; i += chunk) {
|
|
opts.onToken(text.slice(i, i + chunk))
|
|
}
|
|
}
|
|
opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' })
|
|
return result
|
|
}
|
|
|
|
async function completeWithSdk(history, profile, opts) {
|
|
let toolDefs = profile.tools
|
|
? deps.tools.defsForRole({ profileId: profile.id, profile })
|
|
: undefined
|
|
const ctxSize = profile.ctxSize || 8192
|
|
let toolsTok = estimateToolsTokens(toolDefs)
|
|
let budget = promptBudget(ctxSize, toolsTok)
|
|
let overflowRetries = 0
|
|
let messages = compactMessages(
|
|
history.map((m) => ({ role: m.role, content: m.content, name: m.name })),
|
|
{ maxTokens: budget, keepRecentUserTurns: 3, maxToolChars: 2000 }
|
|
)
|
|
|
|
for (let round = 0; round < 4; round++) {
|
|
touchActivity()
|
|
// Re-compact each tool round (payloads grow fast)
|
|
messages = compactMessages(messages, {
|
|
maxTokens: budget,
|
|
keepRecentUserTurns: round === 0 ? 3 : 2,
|
|
maxToolChars: round === 0 ? 2000 : 1000,
|
|
maxMsgChars: round === 0 ? 3000 : 1600,
|
|
})
|
|
|
|
// Electron main-process completion (streaming via IPC events)
|
|
if (sdkMode === 'main' && ipc) {
|
|
/** @type {(( _e: any, t: string) => void)|null} */
|
|
let onTok = null
|
|
/** @type {(( _e: any, t: string) => void)|null} */
|
|
let onThink = null
|
|
try {
|
|
onTok = (_e, t) => opts.onToken?.(t)
|
|
onThink = (_e, t) => opts.onThinking?.(t)
|
|
ipc.on('peardata:qvac-token', onTok)
|
|
ipc.on('peardata:qvac-thinking', onThink)
|
|
const final = await ipc.invoke('peardata:qvac-complete', {
|
|
history: messages,
|
|
tools: toolDefs,
|
|
})
|
|
if (final?.error && !final.contentText) {
|
|
if (isContextOverflowError(final.error) && overflowRetries < 3) {
|
|
overflowRetries++
|
|
budget = Math.floor(budget * 0.55)
|
|
if (overflowRetries === 1 && toolDefs?.length) {
|
|
// First overflow: drop deep tools, keep core
|
|
toolDefs = deps.tools.defsForRole({
|
|
profileId: profile.id,
|
|
depth: 'core',
|
|
})
|
|
toolsTok = estimateToolsTokens(toolDefs)
|
|
budget = promptBudget(ctxSize, toolsTok)
|
|
} else if (overflowRetries >= 2) {
|
|
// Drop tools schema on last retries — frees a lot of context
|
|
toolDefs = undefined
|
|
toolsTok = 0
|
|
budget = promptBudget(ctxSize, 0)
|
|
}
|
|
messages = compactMessages(messages, {
|
|
maxTokens: Math.max(600, budget),
|
|
keepRecentUserTurns: 1,
|
|
maxToolChars: 500,
|
|
maxMsgChars: 900,
|
|
})
|
|
deps.log?.(
|
|
`QVAC context overflow — compact #${overflowRetries} budget~${budget}`
|
|
)
|
|
opts.onToken?.(
|
|
'\n_[Context compacted to fit the model window — retrying…]_\n'
|
|
)
|
|
round = Math.max(-1, round - 1) // retry this tool-loop round
|
|
continue
|
|
}
|
|
return {
|
|
contentText: `Model error: ${final.error}`,
|
|
toolCalls: [],
|
|
mode: 'fallback',
|
|
}
|
|
}
|
|
const calls = (final.toolCalls || []).filter(Boolean)
|
|
// Prefer raw content (may include <think>); UI partitions it
|
|
let text = final.contentText || ''
|
|
if (final.thinkingText && !/<think/i.test(text)) {
|
|
text = `<think>\n${final.thinkingText}\n</think>\n${text}`
|
|
}
|
|
if (!calls.length) {
|
|
return {
|
|
contentText: text,
|
|
toolCalls: [],
|
|
mode: 'qvac',
|
|
stats: final.stats,
|
|
}
|
|
}
|
|
messages = [...messages, { role: 'assistant', content: text }]
|
|
for (const tc of calls) {
|
|
const name = tc.name || tc.function?.name
|
|
let args = tc.arguments || tc.function?.arguments || {}
|
|
if (typeof args === 'string') {
|
|
try {
|
|
args = JSON.parse(args)
|
|
} catch {
|
|
args = {}
|
|
}
|
|
}
|
|
const result = await deps.tools.run(name, args)
|
|
opts.onTool?.(name, args, result)
|
|
// Compact tool results for model context (keep numbers, drop noise)
|
|
const payload = compactToolResult(name, result)
|
|
messages.push({
|
|
role: 'tool',
|
|
content: payload,
|
|
name,
|
|
})
|
|
}
|
|
continue
|
|
} finally {
|
|
if (onTok) {
|
|
try {
|
|
ipc.removeListener('peardata:qvac-token', onTok)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (onThink) {
|
|
try {
|
|
ipc.removeListener('peardata:qvac-thinking', onThink)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const run = sdk.completion({
|
|
modelId,
|
|
history: messages,
|
|
stream: true,
|
|
tools: toolDefs,
|
|
captureThinking: true,
|
|
})
|
|
|
|
let content = ''
|
|
/** @type {Array<{ name: string, arguments?: any, id?: string }>} */
|
|
const toolCalls = []
|
|
|
|
if (run.events) {
|
|
for await (const ev of run.events) {
|
|
opts.onEvent?.(ev)
|
|
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
|
|
opts.onThinking?.(ev.text || ev.delta)
|
|
} else if (ev.type === 'contentDelta' && ev.text) {
|
|
content += ev.text
|
|
opts.onToken?.(ev.text)
|
|
} else if (ev.type === 'toolCall') {
|
|
const call = ev.call || ev.toolCall || ev
|
|
toolCalls.push({
|
|
name: call.name || ev.name,
|
|
arguments: call.arguments || ev.arguments || {},
|
|
id: call.id || ev.id,
|
|
})
|
|
}
|
|
}
|
|
} else if (run.tokenStream) {
|
|
for await (const token of run.tokenStream) {
|
|
content += token
|
|
opts.onToken?.(token)
|
|
}
|
|
}
|
|
|
|
const final = run.final ? await run.final : { contentText: content, toolCalls }
|
|
|
|
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
|
|
if (!calls.length) {
|
|
return {
|
|
contentText: final.contentText || content,
|
|
toolCalls: [],
|
|
mode: 'qvac',
|
|
stats: final.stats,
|
|
}
|
|
}
|
|
|
|
messages = [...messages, { role: 'assistant', content: final.contentText || content || '' }]
|
|
for (const tc of calls) {
|
|
const name = tc.name || tc.function?.name
|
|
let args = tc.arguments || tc.function?.arguments || {}
|
|
if (typeof args === 'string') {
|
|
try {
|
|
args = JSON.parse(args)
|
|
} catch {
|
|
args = {}
|
|
}
|
|
}
|
|
const result = await deps.tools.run(name, args)
|
|
opts.onTool?.(name, args, result)
|
|
messages.push({
|
|
role: 'tool',
|
|
content: compactToolResult(name, result),
|
|
name,
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
contentText: 'Tool loop limit reached. Try a more specific question.',
|
|
toolCalls: [],
|
|
mode: 'qvac',
|
|
}
|
|
}
|
|
|
|
return {
|
|
checkEnvironment,
|
|
loadProfile,
|
|
unload,
|
|
complete,
|
|
getStatus,
|
|
tryLoadSdk,
|
|
touchActivity,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Keep tool JSON small enough for small local models while preserving KPIs.
|
|
* @param {string} name
|
|
* @param {any} result
|
|
*/
|
|
function compactToolResult(name, result) {
|
|
try {
|
|
if (result == null) return 'null'
|
|
if (typeof result === 'string') return result.slice(0, 8_000)
|
|
if (result.error) return JSON.stringify({ error: result.error }).slice(0, 4_000)
|
|
|
|
if (name === 'investigate_host' && typeof result === 'object') {
|
|
const slim = {
|
|
hostname: result.hostname,
|
|
summary: result.summary,
|
|
findings: (result.findings || []).slice(0, 16),
|
|
kpis: result.kpis,
|
|
health: result.health,
|
|
hot: (result.hot || []).slice(0, 10),
|
|
processes: result.processes?.top
|
|
? {
|
|
supported: result.processes.supported,
|
|
top: (result.processes.top || []).slice(0, 10),
|
|
}
|
|
: result.processes,
|
|
catalog: result.catalog,
|
|
}
|
|
return JSON.stringify(slim).slice(0, 12_000)
|
|
}
|
|
|
|
if (name === 'host_snapshot' && typeof result === 'object') {
|
|
const slim = {
|
|
hostname: result.hostname,
|
|
health: result.health,
|
|
kpis: result.kpis,
|
|
anomalies: (result.anomalies || []).slice(0, 8),
|
|
alerts: (result.alerts || []).slice(0, 8),
|
|
catalog: result.catalog,
|
|
cores: result.cores,
|
|
totalRamBytes: result.totalRamBytes,
|
|
freeRamBytes: result.freeRamBytes,
|
|
}
|
|
return JSON.stringify(slim).slice(0, 10_000)
|
|
}
|
|
|
|
if (
|
|
(name === 'summarize_chart' || name === 'compare_chart_windows') &&
|
|
typeof result === 'object'
|
|
) {
|
|
return JSON.stringify(result).slice(0, 8_000)
|
|
}
|
|
|
|
if (name === 'summarize_charts' && result.results) {
|
|
return JSON.stringify({
|
|
after: result.after,
|
|
count: result.count,
|
|
results: (result.results || []).slice(0, 12),
|
|
}).slice(0, 10_000)
|
|
}
|
|
|
|
if (name === 'hot_metrics' && result.results) {
|
|
return JSON.stringify({
|
|
window: result.window,
|
|
count: result.count,
|
|
results: (result.results || []).slice(0, 15),
|
|
}).slice(0, 8_000)
|
|
}
|
|
|
|
if (name === 'related_charts' && result.results) {
|
|
return JSON.stringify({
|
|
chart: result.chart,
|
|
count: result.count,
|
|
results: (result.results || []).slice(0, 12),
|
|
}).slice(0, 6_000)
|
|
}
|
|
|
|
if (name === 'list_processes' && result.processes) {
|
|
return JSON.stringify({
|
|
processes: (result.processes || []).slice(0, 12).map((p) => ({
|
|
pid: p.pid,
|
|
name: p.name || p.comm,
|
|
cpu: p.cpu,
|
|
rss: p.rss,
|
|
})),
|
|
}).slice(0, 6_000)
|
|
}
|
|
|
|
if (name === 'query_metric' && typeof result === 'object') {
|
|
// Raw series can be huge — keep labels + last N rows
|
|
const data = Array.isArray(result.data) ? result.data.slice(-40) : result.data
|
|
return JSON.stringify({
|
|
labels: result.labels,
|
|
points: data?.length,
|
|
data,
|
|
error: result.error,
|
|
}).slice(0, 8_000)
|
|
}
|
|
|
|
return JSON.stringify(result).slice(0, 10_000)
|
|
} catch {
|
|
return String(result).slice(0, 4_000)
|
|
}
|
|
}
|