Add the ability to stop inference
Release rolling / release (push) Canceled after 3m53s

This commit is contained in:
Raven Scott
2026-07-30 17:29:12 -04:00
parent 6581b826cb
commit 6abc5999f2
7 changed files with 471 additions and 128 deletions
+208 -56
View File
@@ -95,6 +95,47 @@ function getElectronIpc() {
}
export function createQvacEngine(deps) {
/** Soft cancel for current complete() / multi-round tool loop */
let abortGeneration = false
/** @type {string|null} */
let activeRequestId = null
/**
* Stop in-flight inference (and pending tool rounds).
* Safe to call when idle.
*/
async function stop() {
abortGeneration = true
const ipc = getElectronIpc()
try {
if (ipc && sdkMode === 'main') {
await ipc.invoke('peardata:qvac-cancel')
} else if (sdk?.cancel) {
if (activeRequestId) {
try {
await sdk.cancel({ requestId: activeRequestId })
} catch {
// ignore
}
}
if (modelId) {
try {
await sdk.cancel({ modelId, kind: 'completion' })
} catch {
// ignore
}
}
}
} catch (err) {
deps.log?.(`QVAC stop: ${err?.message || err}`)
}
return { ok: true }
}
function isAborted() {
return abortGeneration
}
/** @type {any} */
let sdk = null
/** @type {'direct'|'main'|null} */
@@ -442,6 +483,7 @@ export function createQvacEngine(deps) {
*/
async function complete(history, opts = {}) {
touchActivity()
abortGeneration = false
const profile = getProfile(profileId || 'recommended')
const prefs = deps.getPrefs?.() || {}
const userLast = [...history].reverse().find((m) => m.role === 'user')
@@ -471,6 +513,10 @@ export function createQvacEngine(deps) {
...history.filter((m) => m.role !== 'system'),
]
if (abortGeneration) {
return { contentText: '_(Stopped.)_', toolCalls: [], mode: 'stopped', stopped: true }
}
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
return completeWithSdk(fullHistory, profile, opts, prefs)
}
@@ -478,18 +524,40 @@ export function createQvacEngine(deps) {
status = status === 'ready' ? status : 'fallback'
// If multi-agent pack already ran, prefer its synthesis over naive fallback
if (opts.agentFallbackText) {
if (abortGeneration) {
return { contentText: '_(Stopped.)_', toolCalls: [], mode: 'stopped', stopped: true }
}
const text = String(opts.agentFallbackText)
if (opts.onToken) {
const chunk = 24
for (let i = 0; i < text.length; i += chunk) opts.onToken(text.slice(i, i + chunk))
for (let i = 0; i < text.length; i += chunk) {
if (abortGeneration) break
opts.onToken(text.slice(i, i + chunk))
}
}
opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' })
return { contentText: text, toolCalls: opts.agentToolCalls || [], mode: 'fallback' }
opts.onEvent?.({ type: 'completionDone', stopReason: abortGeneration ? 'cancelled' : 'eos' })
return {
contentText: abortGeneration ? `${text}\n\n_(Stopped.)_` : text,
toolCalls: opts.agentToolCalls || [],
mode: abortGeneration ? 'stopped' : 'fallback',
stopped: abortGeneration,
}
}
if (abortGeneration) {
return { contentText: '_(Stopped.)_', toolCalls: [], mode: 'stopped', stopped: true }
}
const result = await fallbackComplete(userLast?.content || '', deps.tools, {
catalog: deps.getCatalog?.() || {},
rag: prefs.rag !== false,
})
if (abortGeneration) {
return {
contentText: (result.contentText || '') + '\n\n_(Stopped.)_',
toolCalls: result.toolCalls || [],
mode: 'stopped',
stopped: true,
}
}
for (const c of result.toolCalls || []) {
opts.onTool?.(c.name, c.args, c.result)
}
@@ -497,11 +565,16 @@ export function createQvacEngine(deps) {
const text = result.contentText || ''
const chunk = 24
for (let i = 0; i < text.length; i += chunk) {
if (abortGeneration) break
opts.onToken(text.slice(i, i + chunk))
}
}
opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' })
return result
opts.onEvent?.({ type: 'completionDone', stopReason: abortGeneration ? 'cancelled' : 'eos' })
return {
...result,
stopped: abortGeneration,
mode: abortGeneration ? 'stopped' : result.mode,
}
}
async function completeWithSdk(history, profile, opts, prefs = {}) {
@@ -523,6 +596,14 @@ export function createQvacEngine(deps) {
)
for (let round = 0; round < 4; round++) {
if (abortGeneration) {
return {
contentText: '_(Stopped.)_',
toolCalls: [],
mode: 'stopped',
stopped: true,
}
}
touchActivity()
// Re-compact each tool round (payloads grow fast)
messages = compactMessages(messages, {
@@ -539,14 +620,32 @@ export function createQvacEngine(deps) {
/** @type {(( _e: any, t: string) => void)|null} */
let onThink = null
try {
onTok = (_e, t) => opts.onToken?.(t)
onThink = (_e, t) => opts.onThinking?.(t)
onTok = (_e, t) => {
if (!abortGeneration) opts.onToken?.(t)
}
onThink = (_e, t) => {
if (!abortGeneration) 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?.stopped || abortGeneration) {
let text = final?.contentText || ''
if (final?.thinkingText && !/<think/i.test(text)) {
text = `<think>\n${final.thinkingText}\n</think>\n${text}`
}
if (!text.trim()) text = '_(Stopped.)_'
else if (!/stopped/i.test(text)) text += '\n\n_(Stopped.)_'
return {
contentText: text,
toolCalls: [],
mode: 'stopped',
stopped: true,
}
}
if (final?.error && !final.contentText) {
if (isContextOverflowError(final.error) && overflowRetries < 3) {
overflowRetries++
@@ -602,6 +701,14 @@ export function createQvacEngine(deps) {
}
messages = [...messages, { role: 'assistant', content: text }]
for (const tc of calls) {
if (abortGeneration) {
return {
contentText: (text || '') + '\n\n_(Stopped.)_',
toolCalls: [],
mode: 'stopped',
stopped: true,
}
}
const name = tc.name || tc.function?.name
let args = tc.arguments || tc.function?.arguments || {}
if (typeof args === 'string') {
@@ -647,65 +754,108 @@ export function createQvacEngine(deps) {
tools: toolDefs,
captureThinking: true,
})
activeRequestId = run.requestId || run.id || null
let content = ''
/** @type {Array<{ name: string, arguments?: any, id?: string }>} */
const toolCalls = []
let streamStopped = false
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,
})
try {
if (run.events) {
for await (const ev of run.events) {
if (abortGeneration) {
streamStopped = true
break
}
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) {
if (abortGeneration) {
streamStopped = true
break
}
content += token
opts.onToken?.(token)
}
}
} 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 = {}
if (streamStopped || abortGeneration) {
return {
contentText: (content || '') + (content ? '\n\n' : '') + '_(Stopped.)_',
toolCalls: [],
mode: 'stopped',
stopped: true,
}
}
const result = await deps.tools.run(name, args)
opts.onTool?.(name, args, result)
messages.push({
role: 'tool',
content: compactToolResult(name, result),
name,
})
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) {
if (abortGeneration) {
return {
contentText: (final.contentText || content || '') + '\n\n_(Stopped.)_',
toolCalls: [],
mode: 'stopped',
stopped: true,
}
}
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,
})
}
} finally {
activeRequestId = null
}
}
if (abortGeneration) {
return {
contentText: '_(Stopped.)_',
toolCalls: [],
mode: 'stopped',
stopped: true,
}
}
@@ -721,6 +871,8 @@ export function createQvacEngine(deps) {
loadProfile,
unload,
complete,
stop,
isAborted,
getStatus,
tryLoadSdk,
touchActivity,