Updates
Rolling release / release (push) Failing after 1m49s

This commit is contained in:
2026-09-12 19:36:06 -04:00
parent 9ad09b593c
commit d47e9e260f
30 changed files with 917 additions and 162 deletions
+29 -4
View File
@@ -11,6 +11,9 @@ const CHAR_PER_TOKEN = 3;
const THRESHOLD = 0.68;
const MIN_SUMMARY = 80;
const MIN_COMPACT_MESSAGES = 4;
// Spoken sessions only need the last few exchanges. Prefill cost tracks the
// prompt, so a 32k model must not keep a 20k-token voice transcript.
const VOICE_KEEP_TOKENS = 1024;
const COMPACT_PROMPT =
'Summarize this coding-agent conversation. Use exactly these sections:\n' +
'1. Goal\n' +
@@ -75,19 +78,38 @@ function estimateTokens(messages, tools) {
return conversationTokens(messages) + toolTokens(tools);
}
function historyBudget(ctxSize, tools, attempt, extraTokens = 0) {
function dialogTokens(messages) {
return conversationTokens((messages || []).filter((m) => m && m.role !== 'system'));
}
function voiceKeepTokens(ctxSize) {
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
return Math.min(VOICE_KEEP_TOKENS, Math.max(480, Math.floor(cap * 0.28)));
}
function historyBudget(ctxSize, tools, attempt, extraTokens = 0, opts = {}) {
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
const toolTok = toolTokens(tools);
const reserve = Math.max(384, Math.floor(cap * (0.18 + (Number(attempt) || 0) * 0.08)));
return Math.max(240, Math.floor(cap * 0.72) - toolTok - reserve - extraTokens);
let budget = Math.max(240, Math.floor(cap * 0.72) - toolTok - reserve - extraTokens);
if (opts && opts.voice) {
const sysTok = Math.max(0, Number(opts.systemTokens) || 0);
budget = Math.min(budget, Math.max(240, sysTok + voiceKeepTokens(cap) - extraTokens));
}
return Math.max(240, budget);
}
function shouldCompact(messages, tools, ctxSize, extraTokens = 0) {
function shouldCompact(messages, tools, ctxSize, extraTokens = 0, opts = {}) {
const dialog = dialogTokens(messages);
const enough = nonSystemCount(messages) >= MIN_COMPACT_MESSAGES || dialog > 1024;
if (opts && opts.voice) {
return dialog > voiceKeepTokens(ctxSize) && enough;
}
const budget = historyBudget(ctxSize, tools, 0, extraTokens);
const tokens = conversationTokens(messages);
// Large first requests or tool results can overflow before four messages.
// A small greeting must not be discarded merely because schemas are large.
return tokens > budget && (nonSystemCount(messages) >= MIN_COMPACT_MESSAGES || conversationTokens((messages || []).filter((m) => m.role !== 'system')) > 1024);
return tokens > budget && enough;
}
function isOverflowError(err) {
@@ -329,11 +351,14 @@ module.exports = {
THRESHOLD,
MIN_SUMMARY,
MIN_COMPACT_MESSAGES,
VOICE_KEEP_TOKENS,
COMPACT_PROMPT,
VOICE_COMPACT_PROMPT,
conversationTokens,
dialogTokens,
toolTokens,
estimateTokens,
voiceKeepTokens,
historyBudget,
shouldCompact,
nonSystemCount,
+27 -11
View File
@@ -72,6 +72,22 @@ function loadedCtxSize() {
return (loaded && loaded.ctxSize) || 8192;
}
function systemTokens(history) {
if (history && history[0] && history[0].role === 'system') return compaction.conversationTokens([history[0]]);
return 0;
}
function historyCompactOpts(session, toolDefs, ctxSize, sidecarTokens, budget, attempt) {
return {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, attempt || 0, sidecarTokens, {
voice: !!budget.voice,
systemTokens: systemTokens(session.history),
}),
tools: toolDefs,
voice: !!budget.voice,
};
}
function toolResultCap(budget) {
const ctx = loadedCtxSize();
const voice = !!(budget && budget.voice);
@@ -608,22 +624,20 @@ async function runTurn(ctx) {
const sidecarTokens = compaction.conversationTokens(turnSidecars) + 256;
const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize);
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, beforeUsage));
if (compaction.shouldCompact(session.history, toolDefs, ctxSize, sidecarTokens)) {
if (compaction.shouldCompact(session.history, toolDefs, ctxSize, sidecarTokens, { voice: !!budget.voice })) {
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'start',
method: 'llm',
method: budget.voice ? 'heuristic' : 'llm',
used: beforeUsage.used,
limit: beforeUsage.limit,
pct: beforeUsage.pct,
threshold: beforeUsage.threshold,
});
const compactOpts = {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0, sidecarTokens),
tools: toolDefs,
voice: !!budget.voice,
};
session.history = await compaction.compactWithLlm(session.history, Object.assign({}, compactOpts, {
const compactOpts = historyCompactOpts(session, toolDefs, ctxSize, sidecarTokens, budget, 0);
session.history = budget.voice
? compaction.compact(session.history, compactOpts)
: await compaction.compactWithLlm(session.history, Object.assign({}, compactOpts, {
complete: (opts) => engine.complete(Object.assign({}, opts, {
desktopVision: false,
timeoutMs: budget.completeTimeoutMs,
@@ -643,7 +657,7 @@ async function runTurn(ctx) {
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'done',
method: 'llm',
method: budget.voice ? 'heuristic' : 'llm',
used: afterUsage.used,
limit: afterUsage.limit,
pct: afterUsage.pct,
@@ -715,7 +729,7 @@ async function runTurn(ctx) {
// The estimate can be lower than the model's tokenizer count.
// Every overflow retry must shrink even an apparently small history.
budgetTokens: Math.min(
compaction.historyBudget(ctxSize, toolDefs, overflowTry + 1, sidecarTokens),
historyCompactOpts(session, toolDefs, ctxSize, sidecarTokens, budget, overflowTry + 1).budgetTokens,
Math.max(1, Math.floor(compaction.conversationTokens(session.history) * 0.85))
),
tools: toolDefs,
@@ -863,7 +877,9 @@ async function runTurn(ctx) {
}
if (stopEarly) break;
}
if (prepared.length) toolBudget.markToolRound(budget);
if (prepared.some((item) => item.name !== 'todo_write' && item.name !== 'update_goal')) {
toolBudget.markToolRound(budget);
}
if (stopEarly) {
return endTurn(emit, session, jobId, tracker, stopEarly);
}
+1 -1
View File
@@ -18,7 +18,7 @@ function fromPayload(payload, origin) {
voice,
maxTurns: num(payload.maxTurns, voice ? 6 : 24),
maxShellCalls: unlimitedShell ? 0 : num(payload.maxShellCalls, voice ? 1 : 0),
maxToolRounds: num(payload.maxToolRounds, voice ? 4 : 0),
maxToolRounds: num(payload.maxToolRounds, voice ? 6 : 0),
completeTimeoutMs: voice ? 45000 : 0,
completeIdleMs: voice ? 10000 : 0,
shellCalls: 0,
+161 -68
View File
@@ -7,8 +7,10 @@
const net = require('../lib/net.js');
const truncate = require('./truncate.js');
const WEB_TIMEOUT_MS = 12000;
const PAGE_TIMEOUT_MS = 20000;
const WEB_TIMEOUT_MS = 3500;
const PAGE_TIMEOUT_MS = 8000;
const SEARCH_BUDGET_MS = 8000;
const ENGINE_TIMEOUT_MS = 3000;
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const GOOGLE_UA =
@@ -34,14 +36,14 @@ const ENGINE_NAMES = [
];
const AUTO_ENGINES = [
'duckduckgo',
'ddg_lite',
'jina',
'bing',
'bing_rss',
'google',
'wikipedia',
'ddg_instant',
'ddg_lite',
'bing_rss',
'duckduckgo',
'bing',
'google',
];
const ENGINE_ALIASES = {
@@ -61,13 +63,64 @@ function abortError(timeoutMs) {
return err;
}
function remainingMs(deadline) {
return Math.max(0, Number(deadline) - Date.now());
}
function timeoutErrorResult(ms, extra) {
return Object.assign({ error: 'timed out after ' + ms + 'ms' }, extra || {});
}
function budgetMs(timeoutMs, fallback, max) {
const fallbackMs = Number(fallback) > 0 ? Number(fallback) : SEARCH_BUDGET_MS;
const cap = Number(max) > 0 ? Number(max) : fallbackMs;
const n = Number(timeoutMs);
if (!(n > 0)) return fallbackMs;
return n > cap ? cap : n;
}
function withDeadline(work, deadline, fallback) {
const left = remainingMs(deadline);
if (left <= 0) return Promise.resolve(typeof fallback === 'function' ? fallback() : fallback);
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve(typeof fallback === 'function' ? fallback() : fallback), left);
});
return Promise.race([Promise.resolve().then(work), timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
function linkAbort(parent, child) {
if (!parent || !child) return;
if (parent.aborted) {
try {
child.abort();
} catch (_) {}
return;
}
parent.addEventListener(
'abort',
() => {
try {
child.abort();
} catch (_) {}
},
{ once: true },
);
}
function fetchWithTimeout(url, opts, timeoutMs) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
if (!(ms > 0)) return Promise.reject(abortError(0));
const headers = Object.assign({ 'user-agent': BROWSER_UA }, (opts && opts.headers) || {});
const controller = typeof AbortController === 'function' ? new AbortController() : null;
let timer;
const init = Object.assign({}, opts || {}, { headers });
if (controller) init.signal = controller.signal;
if (controller) {
init.signal = controller.signal;
if (opts && opts.signal) linkAbort(opts.signal, controller);
}
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
try {
@@ -84,7 +137,8 @@ function fetchWithTimeout(url, opts, timeoutMs) {
}
function readBodyWithTimeout(res, timeoutMs) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : 0;
if (!(ms > 0)) return Promise.reject(abortError(0));
if (!res || typeof res.text !== 'function') return Promise.resolve('');
let timer;
const timeout = new Promise((_, reject) => {
@@ -238,14 +292,16 @@ function pushHit(hits, seen, href, title, snippet, limit) {
}
async function fetchText(url, timeoutMs, opts) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
const deadline = Date.now() + ms;
try {
net.assertPublicHttpUrl(url);
} catch (err) {
return { error: String(err && err.message || err), url };
}
try {
const res = await fetchWithTimeout(url, opts || {}, timeoutMs);
const text = await readBodyWithTimeout(res, timeoutMs);
const res = await fetchWithTimeout(url, opts || {}, remainingMs(deadline));
const text = await readBodyWithTimeout(res, remainingMs(deadline));
if (res.status >= 400) {
return { error: 'HTTP ' + res.status, url: String(res.url || url), status: res.status, text };
}
@@ -381,9 +437,11 @@ function isDdgChallenge(html) {
}
async function duckDuckGoSearch(query, timeoutMs, limit) {
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
const deadline = Date.now() + ms;
const url = 'https://html.duckduckgo.com/html/';
const body = 'q=' + encodeURIComponent(query) + '&b=&kl=us-en';
let page = await fetchText(url, timeoutMs, {
let page = await fetchText(url, remainingMs(deadline), {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
@@ -397,7 +455,8 @@ async function duckDuckGoSearch(query, timeoutMs, limit) {
const posted = parseDdgHtmlHits(page.text, limit);
if (searchHasHits(posted)) return posted;
}
page = await fetchText(url + '?q=' + encodeURIComponent(query), timeoutMs);
if (remainingMs(deadline) <= 0) return { error: 'timed out after ' + ms + 'ms', url };
page = await fetchText(url + '?q=' + encodeURIComponent(query), remainingMs(deadline));
if (page.error) return page;
if (isDdgChallenge(page.text)) return { error: 'duckduckgo bot challenge', url: page.url, status: page.status };
return parseDdgHtmlHits(page.text, limit);
@@ -633,32 +692,48 @@ async function runWebSearch(query, opts) {
const q = String(query || '').trim();
if (!q) return { error: 'query required' };
const limit = clampLimit(opts.limit);
const timeoutMs = opts.timeoutMs;
const budget = budgetMs(opts.timeoutMs, SEARCH_BUDGET_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + budget;
const engine = resolveEngine(opts.engine);
if (engine !== 'auto') {
const fn = SEARCH_ENGINES[engine];
if (!fn) return { error: 'unknown engine', engine: opts.engine, engines: ENGINE_NAMES };
const result = await fn(q, timeoutMs, limit);
if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit);
return {
error: (result && result.error) || 'no search results',
url: result && result.url,
tried: [engine],
engines: ENGINE_NAMES,
};
}
const prefer = Array.isArray(opts.prefer) ? opts.prefer.map(resolveEngine).filter((n) => SEARCH_ENGINES[n]) : [];
const chain = prefer.concat(AUTO_ENGINES.filter((name) => prefer.indexOf(name) < 0));
const tried = [];
const errors = {};
for (let i = 0; i < chain.length; i++) {
const name = chain[i];
tried.push(name);
const result = await SEARCH_ENGINES[name](q, timeoutMs, limit);
if (searchHasHits(result)) return tagSearchHits(result, name).slice(0, limit);
errors[name] = result && result.error ? result.error : 'no results';
}
return { error: 'no search results', tried, errors, engines: ENGINE_NAMES };
const timedOut = () => timeoutErrorResult(budget, {
tried: tried.slice(),
errors: Object.assign({}, errors),
engines: ENGINE_NAMES,
});
return withDeadline(async () => {
try {
if (engine !== 'auto') {
const fn = SEARCH_ENGINES[engine];
if (!fn) return { error: 'unknown engine', engine: opts.engine, engines: ENGINE_NAMES };
tried.push(engine);
const result = await fn(q, remainingMs(deadline), limit);
if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit);
return {
error: (result && result.error) || 'no search results',
url: result && result.url,
tried: [engine],
engines: ENGINE_NAMES,
};
}
const prefer = Array.isArray(opts.prefer) ? opts.prefer.map(resolveEngine).filter((n) => SEARCH_ENGINES[n]) : [];
const chain = prefer.concat(AUTO_ENGINES.filter((name) => prefer.indexOf(name) < 0));
for (let i = 0; i < chain.length; i++) {
const left = remainingMs(deadline);
if (left <= 0) return timedOut();
if (tried.length && left < 50) break;
const name = chain[i];
tried.push(name);
const result = await SEARCH_ENGINES[name](q, Math.min(ENGINE_TIMEOUT_MS, left), limit);
if (searchHasHits(result)) return tagSearchHits(result, name).slice(0, limit);
errors[name] = result && result.error ? result.error : 'no results';
}
return { error: 'no search results', tried, errors, engines: ENGINE_NAMES };
} catch (err) {
return { error: String(err && err.message || err), tried, errors, engines: ENGINE_NAMES };
}
}, deadline, timedOut);
}
async function googleSearchWithFallback(query, timeoutMs) {
@@ -672,22 +747,27 @@ async function webSearch(query, timeoutMs) {
async function codeSearch(query, timeoutMs, limit) {
const q = String(query || '').trim();
if (!q) return { error: 'query required' };
const [github, npm, mdn] = await Promise.all([
githubSearch(q, timeoutMs, limit),
npmSearch(q, timeoutMs, limit),
mdnSearch(q, timeoutMs, limit),
]);
const out = { github: [], npm: [], mdn: [] };
if (searchHasHits(github)) out.github = tagSearchHits(github, 'github');
else if (github && github.error) out.github_error = github.error;
if (searchHasHits(npm)) out.npm = tagSearchHits(npm, 'npm');
else if (npm && npm.error) out.npm_error = npm.error;
if (searchHasHits(mdn)) out.mdn = tagSearchHits(mdn, 'mdn');
else if (mdn && mdn.error) out.mdn_error = mdn.error;
if (!out.github.length && !out.npm.length && !out.mdn.length) {
return { error: 'no code search results', github_error: out.github_error, npm_error: out.npm_error, mdn_error: out.mdn_error };
}
return out;
const budget = budgetMs(timeoutMs, SEARCH_BUDGET_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + budget;
return withDeadline(async () => {
const slice = remainingMs(deadline);
const [github, npm, mdn] = await Promise.all([
githubSearch(q, slice, limit),
npmSearch(q, slice, limit),
mdnSearch(q, slice, limit),
]);
const out = { github: [], npm: [], mdn: [] };
if (searchHasHits(github)) out.github = tagSearchHits(github, 'github');
else if (github && github.error) out.github_error = github.error;
if (searchHasHits(npm)) out.npm = tagSearchHits(npm, 'npm');
else if (npm && npm.error) out.npm_error = npm.error;
if (searchHasHits(mdn)) out.mdn = tagSearchHits(mdn, 'mdn');
else if (mdn && mdn.error) out.mdn_error = mdn.error;
if (!out.github.length && !out.npm.length && !out.mdn.length) {
return { error: 'no code search results', github_error: out.github_error, npm_error: out.npm_error, mdn_error: out.mdn_error };
}
return out;
}, deadline, () => timeoutErrorResult(budget));
}
async function webFetch(url, timeoutMs) {
@@ -696,9 +776,11 @@ async function webFetch(url, timeoutMs) {
} catch (err) {
return { error: String(err && err.message || err), url: String(url || '') };
}
const ms = budgetMs(timeoutMs, WEB_TIMEOUT_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + ms;
try {
const res = await fetchWithTimeout(url, {}, timeoutMs);
let text = htmlToText(await readBodyWithTimeout(res, timeoutMs));
const res = await fetchWithTimeout(url, {}, remainingMs(deadline));
let text = htmlToText(await readBodyWithTimeout(res, remainingMs(deadline)));
text = truncate.truncateWithMarker(text, 12000);
const href = String(res.url || url);
if (res.status >= 400) {
@@ -718,26 +800,37 @@ async function fetchPage(url, timeoutMs) {
}
const target = String(url);
const jinaUrl = 'https://r.jina.ai/' + target;
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : PAGE_TIMEOUT_MS;
try {
net.assertPublicHttpUrl(jinaUrl);
const res = await fetchWithTimeout(jinaUrl, { headers: { accept: 'text/plain', 'user-agent': AGENT_UA } }, ms);
if (res.status < 400) {
let text = await readBodyWithTimeout(res, ms);
text = truncate.truncateWithMarker(text, 12000);
if (text && text.length > 24 && !/verifying you are (a )?human/i.test(text)) {
return { status: res.status, url: target, text, via: 'jina' };
const ms = budgetMs(timeoutMs, PAGE_TIMEOUT_MS, SEARCH_BUDGET_MS);
const deadline = Date.now() + ms;
const timedOut = () => timeoutErrorResult(ms, { url: target });
return withDeadline(async () => {
try {
net.assertPublicHttpUrl(jinaUrl);
const left = remainingMs(deadline);
if (left > 0) {
const res = await fetchWithTimeout(jinaUrl, { headers: { accept: 'text/plain', 'user-agent': AGENT_UA } }, left);
if (res.status < 400) {
let text = await readBodyWithTimeout(res, remainingMs(deadline));
text = truncate.truncateWithMarker(text, 12000);
if (text && text.length > 24 && !/verifying you are (a )?human/i.test(text)) {
return { status: res.status, url: target, text, via: 'jina' };
}
}
}
}
} catch (_) {}
const raw = await webFetch(target, timeoutMs);
if (raw && !raw.via) raw.via = 'raw';
return raw;
} catch (_) {}
const left = remainingMs(deadline);
if (left <= 0) return timedOut();
const raw = await webFetch(target, left);
if (raw && !raw.via) raw.via = 'raw';
return raw;
}, deadline, timedOut);
}
module.exports = {
WEB_TIMEOUT_MS,
PAGE_TIMEOUT_MS,
SEARCH_BUDGET_MS,
ENGINE_TIMEOUT_MS,
BROWSER_UA,
GOOGLE_UA,
AGENT_UA,
+8 -3
View File
@@ -63,11 +63,16 @@ function wrapSession(summary, opts) {
const session = sessions.load(sessionId);
const ctxSize = (engine.getLoaded() && engine.getLoaded().ctxSize) || 8192;
const toolDefs = [];
session.history = await require('./agent/compaction.js').compactWithLlm(session.history, {
budgetTokens: require('./agent/compaction.js').historyBudget(ctxSize, toolDefs, 0),
const compactOpts = {
budgetTokens: require('./agent/compaction.js').historyBudget(ctxSize, toolDefs, 0, 0, { voice: !!opts.voice }),
tools: toolDefs,
voice: !!opts.voice,
};
session.history = opts.voice
? require('./agent/compaction.js').compact(session.history, compactOpts)
: await require('./agent/compaction.js').compactWithLlm(session.history, Object.assign({}, compactOpts, {
complete: (o) => engine.complete(Object.assign({}, o, { desktopVision: false })),
});
}));
sessions.replaceHistory(sessionId, session.history);
return session.history;
}
+58 -3
View File
@@ -14,7 +14,7 @@ const CATALOG = [
mmproj: 'MMPROJ_QWEN3_5_0_8B_MULTIMODAL_Q8_0',
minRamGb: 4,
approxDownloadGb: 0.7,
ctxSize: 8192,
ctxSize: 16384,
},
{
id: 'qwen3.5-2b',
@@ -26,7 +26,7 @@ const CATALOG = [
mmproj: 'MMPROJ_QWEN3_5_2B_MULTIMODAL_Q8_0',
minRamGb: 6,
approxDownloadGb: 1.6,
ctxSize: 8192,
ctxSize: 16384,
},
{
id: 'qwen3.5-4b',
@@ -155,7 +155,7 @@ const CATALOG = [
vision: false,
minRamGb: 8,
approxDownloadGb: 1.2,
ctxSize: 8192,
ctxSize: 16384,
},
{
id: 'qwen3-4b',
@@ -315,6 +315,9 @@ const COMPACT_TOOL_ALLOW = [
'memory_search',
'memory_get',
'memory_write',
'todo_write',
'task',
'update_goal',
'capability_status',
'qvac_runtime_state',
'qvac_system_resources',
@@ -332,6 +335,54 @@ function filterToolsForModel(defs, idOrConstant) {
return filtered.length ? filtered : list;
}
function catalogId(idOrConstant) {
const e = findCatalogEntry(idOrConstant);
return String((e && e.id) || idOrConstant || '').toLowerCase();
}
function isQwenReasoningModel(idOrConstant) {
const id = catalogId(idOrConstant);
return /qwen3(?:\.\d+)?/.test(id) || /qwen3[._-]/.test(String(idOrConstant || '').toLowerCase());
}
// Qwen3.5's chat template leaves thinking off unless the reasoning channel is
// enabled. Cap it so HUD thinking still streams, then the addon force-closes
// <think> and the model can emit the tool XML / spoken reply.
function reasoningBudgetForModel(idOrConstant) {
if (!isQwenReasoningModel(idOrConstant)) return null;
const id = catalogId(idOrConstant);
if (/0\.8b|0\.6b|600m/.test(id)) return 128;
if (/1\.7b|(?:^|-)2b/.test(id)) return 192;
if (/(?:^|-)4b/.test(id)) return 320;
if (/(?:^|-)[89]b/.test(id)) return 448;
return 320;
}
// Slightly below Qwen thinking's 0.6 so compact models still follow the
// tool XML, but high enough to avoid the greedy repeat loops of temp 0.3.
function compactGenerationParams(idOrConstant) {
if (!isCompactToolModel(idOrConstant)) return {};
return { temp: 0.55 };
}
function generationParamsForModel(idOrConstant, extra) {
const base = Object.assign(
{
repeat_penalty: 1.15,
frequency_penalty: 0.15,
},
compactGenerationParams(idOrConstant)
);
const budget = reasoningBudgetForModel(idOrConstant);
if (budget != null) {
base.reasoning_budget = budget;
base.predict = Math.min(768, Math.max(384, budget + 288));
} else {
base.predict = isCompactToolModel(idOrConstant) ? 384 : 640;
}
return Object.assign({}, base, extra || {});
}
module.exports = {
CATALOG,
FALLBACK_LLM_IDS,
@@ -346,5 +397,9 @@ module.exports = {
isCompactToolModel,
compactToolAllowlist,
filterToolsForModel,
isQwenReasoningModel,
reasoningBudgetForModel,
compactGenerationParams,
generationParamsForModel,
catalogLabel,
};
+29 -5
View File
@@ -11,6 +11,7 @@ const events = require('./events.js');
const paths = require('./paths.js');
const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js');
const repeatLoop = require('./repeat-loop.js');
let sdk = null;
let initError = null;
@@ -273,6 +274,7 @@ async function load(opts, onProgress) {
const wantVision =
opts.vision !== false && (entry ? entry.vision === true : catalog.isVisionModel(opts.model || opts.modelSrc));
const mmprojSrc = wantVision ? await resolveMmproj(s, entry) : null;
const loadParams = catalog.generationParamsForModel((entry && entry.id) || opts.model);
const modelConfig = Object.assign(
{
device: dev.device,
@@ -281,6 +283,7 @@ async function load(opts, onProgress) {
tools: !!toolsOn,
'mmproj-use-gpu': !!mmprojGpu,
},
loadParams.reasoning_budget != null ? { reasoning_budget: loadParams.reasoning_budget } : {},
mmprojSrc ? { projectionModelSrc: mmprojSrc } : {}
);
const loadOpts = {
@@ -317,7 +320,7 @@ async function load(opts, onProgress) {
} catch (err) {
const msg = flattenError(err);
log('load failed: ' + msg);
if (dev.device === 'gpu' && String(opts.device || 'auto').toLowerCase() !== 'cpu') {
if (dev.device === 'gpu' && String(opts.device || 'auto').toLowerCase() === 'auto' && process.env.JARVIS_GPU_REQUIRED !== '1') {
log('retrying load on cpu');
modelConfig.device = 'cpu';
modelConfig.gpu_layers = 0;
@@ -340,8 +343,8 @@ async function load(opts, onProgress) {
tools: !!toolsOn,
vision: !!mmprojSrc,
device: dev.device,
backend: bl.backend,
backendId: bl.backendId,
backend: dev.device === 'gpu' ? bl.backend : 'cpu',
backendId: dev.device === 'gpu' ? bl.backendId : 0,
deviceName: bl.deviceName,
vram: res && (res.vram || res.vramBytes),
ctxSize: modelConfig.ctx_size,
@@ -371,9 +374,13 @@ async function complete(opts, onEvent) {
const history = prepareVisionHistory(rawHistory);
const tools = toTools(opts && opts.tools);
const dialect = (opts && opts.toolDialect) || catalog.toolDialectFor(loaded.friendlyId || loaded.constant);
const generationParams = catalog.generationParamsForModel(
loaded.friendlyId || loaded.constant,
opts && opts.generationParams
);
const params = {
modelId: (opts && opts.modelId) || loaded.modelId,
history,
history: toolParse.prepareToolHistory(history, dialect),
stream: opts && opts.stream === false ? false : true,
captureThinking: true,
};
@@ -381,7 +388,7 @@ async function complete(opts, onEvent) {
params.tools = tools;
params.toolDialect = dialect;
}
if (opts && opts.generationParams) params.generationParams = opts.generationParams;
if (Object.keys(generationParams).length) params.generationParams = generationParams;
const run = s.completion(params);
const requestId = run.requestId || (opts && opts.requestId) || null;
loaded.requestId = requestId;
@@ -416,9 +423,19 @@ async function complete(opts, onEvent) {
if (n.type === 'contentDelta') {
text += n.delta;
if (onEvent) onEvent(n);
if (repeatLoop.isRepeating(text)) {
abortRun();
settleTimeout();
return;
}
} else if (n.type === 'thinkingDelta') {
thinking += n.delta;
if (onEvent) onEvent(n);
if (repeatLoop.isRepeating(thinking)) {
abortRun();
settleTimeout();
return;
}
} else if (n.type === 'toolCall') {
toolCalls.push(n.call);
if (onEvent) onEvent(n);
@@ -432,6 +449,11 @@ async function complete(opts, onEvent) {
watch.bump();
text += token;
if (onEvent) onEvent({ type: 'contentDelta', delta: token });
if (repeatLoop.isRepeating(text)) {
abortRun();
settleTimeout();
return;
}
}
if (run.toolCallStream) {
for await (const evt of run.toolCallStream) {
@@ -476,6 +498,8 @@ async function complete(opts, onEvent) {
} else {
text = toolParse.stripToolMarkup(text);
}
text = repeatLoop.collapseRepeats(text);
thinking = repeatLoop.collapseRepeats(thinking);
return {
text,
thinking,
+45
View File
@@ -0,0 +1,45 @@
/**
* Detect and collapse degenerate generation loops. No Bare imports.
*
* Small Qwen runs with no predict cap will repeat a clause until the idle
* timeout. Catch the repeating tail and stop the stream.
*/
function repeatingUnit(text, minLen, copies) {
const s = String(text || '');
const min = minLen > 0 ? minLen : 24;
const need = copies > 0 ? copies : 3;
if (s.length < min * need) return '';
const window = s.length > 2400 ? s.slice(-2400) : s;
const maxLen = Math.min(180, Math.floor(window.length / need));
for (let len = min; len <= maxLen; len++) {
const unit = window.slice(-len);
if (unit.replace(/\s+/g, '').length < 12) continue;
let matched = true;
for (let i = 2; i <= need; i++) {
if (window.slice(-len * i, -len * (i - 1)) !== unit) {
matched = false;
break;
}
}
if (matched) return unit;
}
return '';
}
function isRepeating(text) {
return !!repeatingUnit(text);
}
function escapeRe(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function collapseRepeats(text) {
const s = String(text || '');
const unit = repeatingUnit(s);
if (!unit) return s.trim();
return s.replace(new RegExp('(?:' + escapeRe(unit) + '){3,}$'), unit).trim();
}
module.exports = { repeatingUnit, isRepeating, collapseRepeats };
+31 -2
View File
@@ -7,8 +7,8 @@
*/
const FORMAT_REMINDER =
'When you need a tool, close thinking first, then emit this XML (not JSON, not a spoken plan):\n' +
'<tool_call>\n<function=TOOL_NAME>\n<parameter=ARG>value</parameter>\n</function>\n</tool_call>';
'When you need a tool, emit only this XML (not JSON, not a question, not a spoken plan):\n' +
'<tool_call>\n<function=TOOL_NAME>\n<parameter=ARG>\nvalue\n</parameter>\n</function>\n</tool_call>';
const ALIASES = {
search: 'web_search',
@@ -164,6 +164,33 @@ function recover({ text, thinking, tools, existing } = {}) {
return { calls, text: spoken };
}
// Qwen3.5's GGUF template renders assistant.tool_calls as this XML. QVAC's
// completion transport keeps only {role, content}, so later turns would lose
// the call unless we replay the same markup into content first.
function qwen35ToolCallXml(call) {
const fn = call && (call.function || call);
const name = fn && fn.name;
if (!name) return '';
const args = parseJsonArgs(fn.arguments);
const params = Object.entries(args)
.map(([key, value]) =>
'<parameter=' + key + '>\n' + (typeof value === 'string' ? value : JSON.stringify(value)) + '\n</parameter>'
)
.join('\n');
return '<tool_call>\n<function=' + name + '>\n' + (params ? params + '\n' : '') + '</function>\n</tool_call>';
}
function prepareToolHistory(history, dialect) {
if (!Array.isArray(history) || dialect !== 'qwen35') return history;
return history.map((message) => {
if (!message || message.role !== 'assistant' || !message.tool_calls || !message.tool_calls.length) return message;
if (/<tool_call>/i.test(message.content || '')) return message;
const frames = message.tool_calls.map(qwen35ToolCallXml).filter(Boolean);
if (!frames.length) return message;
return Object.assign({}, message, { content: [message.content, ...frames].filter(Boolean).join('\n') });
});
}
module.exports = {
FORMAT_REMINDER,
ALIASES,
@@ -171,4 +198,6 @@ module.exports = {
stripToolMarkup,
recover,
remapName,
qwen35ToolCallXml,
prepareToolHistory,
};
+15
View File
@@ -41,12 +41,27 @@ function testCatalog() {
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-2b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-0.8b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-4b'), false);
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-0.8b').ctxSize, 16384);
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-2b').ctxSize, 16384);
assert.strictEqual(catalog.findCatalogEntry('qwen3-1.7b').ctxSize, 16384);
assert.ok(catalog.findCatalogEntry('qwen3.5-4b').ctxSize >= 8192);
const tiny = catalog.filterToolsForModel(
[{ name: 'web_search' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
'qwen3.5-0.8b',
);
assert.deepStrictEqual(tiny.map((t) => t.name), ['web_search']);
assert.strictEqual(
catalog.filterToolsForModel([{ name: 'todo_write' }, { name: 'cu_drag' }], 'qwen3.5-0.8b')[0].name,
'todo_write',
);
assert.strictEqual(catalog.filterToolsForModel([{ name: 'cu_drag' }], 'qwen3.5-4b')[0].name, 'cu_drag');
assert.deepStrictEqual(catalog.compactGenerationParams('qwen3.5-0.8b'), { temp: 0.55 });
assert.strictEqual(catalog.reasoningBudgetForModel('qwen3.5-0.8b'), 128);
assert.strictEqual(catalog.reasoningBudgetForModel('qwen3.5-4b'), 320);
const largeGen = catalog.generationParamsForModel('qwen3.5-4b', { predict: 64 });
assert.strictEqual(largeGen.reasoning_budget, 320);
assert.strictEqual(largeGen.predict, 64);
assert.strictEqual(largeGen.repeat_penalty, 1.15);
assert.ok(/~5 GB/.test(catalog.catalogLabel(catalog.findCatalogEntry('qwen3-8b'))));
assert.ok(catalog.FALLBACK_LLM_IDS.indexOf('gemma4-4b') >= 0);
const listed = catalog.listCatalog().find((m) => m.id === 'gemma4-2b');