Updates
Rolling release / release (push) Successful in 7m29s

This commit is contained in:
2026-09-12 16:46:49 -04:00
parent 04a9306594
commit 4383763cb5
20 changed files with 490 additions and 26 deletions
+60
View File
@@ -9,6 +9,7 @@ const CATALOG = [
constant: 'QWEN3_5_0_8B_MULTIMODAL_Q4_K_M',
name: 'Qwen3.5 0.8B',
tools: true,
compactTools: true,
vision: true,
mmproj: 'MMPROJ_QWEN3_5_0_8B_MULTIMODAL_Q8_0',
minRamGb: 4,
@@ -20,6 +21,7 @@ const CATALOG = [
constant: 'QWEN3_5_2B_MULTIMODAL_Q4_K_M',
name: 'Qwen3.5 2B',
tools: true,
compactTools: true,
vision: true,
mmproj: 'MMPROJ_QWEN3_5_2B_MULTIMODAL_Q8_0',
minRamGb: 6,
@@ -149,6 +151,7 @@ const CATALOG = [
constant: 'QWEN3_1_7B_INST_Q4',
name: 'Qwen3 1.7B',
tools: true,
compactTools: true,
vision: false,
minRamGb: 8,
approxDownloadGb: 1.2,
@@ -275,6 +278,60 @@ function toolDialectFor(idOrConstant) {
return 'hermes';
}
function isCompactToolModel(idOrConstant) {
const e = findCatalogEntry(idOrConstant);
if (e && e.compactTools) return true;
const id = String((e && e.id) || idOrConstant || '').toLowerCase();
return /qwen3\.5-0\.8b|qwen3\.5-2b|qwen3-1\.7b/.test(id);
}
const COMPACT_TOOL_ALLOW = [
'read_file',
'write_file',
'search_replace',
'list_dir',
'grep',
'run_terminal_cmd',
'web_search',
'google_search',
'fetch_page',
'web_fetch',
'wiki_search',
'hn_search',
'code_search',
'jarvis_status',
'cu_status',
'cu_observe',
'cu_find',
'cu_click',
'cu_type',
'cu_key',
'fs_search',
'fs_read',
'fs_write',
'app_list',
'memory_recall',
'memory_remember',
'memory_search',
'memory_get',
'memory_write',
'capability_status',
'qvac_runtime_state',
'qvac_system_resources',
];
function compactToolAllowlist() {
return new Set(COMPACT_TOOL_ALLOW);
}
function filterToolsForModel(defs, idOrConstant) {
const list = Array.isArray(defs) ? defs : [];
if (!isCompactToolModel(idOrConstant)) return list;
const allow = compactToolAllowlist();
const filtered = list.filter((t) => t && allow.has(t.name));
return filtered.length ? filtered : list;
}
module.exports = {
CATALOG,
FALLBACK_LLM_IDS,
@@ -286,5 +343,8 @@ module.exports = {
mmprojConstant,
mmprojCandidates,
toolDialectFor,
isCompactToolModel,
compactToolAllowlist,
filterToolsForModel,
catalogLabel,
};
+24 -3
View File
@@ -10,6 +10,7 @@ const device = require('./device.js');
const events = require('./events.js');
const paths = require('./paths.js');
const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js');
let sdk = null;
let initError = null;
@@ -154,11 +155,22 @@ function toTools(tools) {
return {
type: 'function',
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
description: t.function.description || t.function.name,
parameters: t.function.parameters && t.function.parameters.type === 'object'
? t.function.parameters
: { type: 'object', properties: {} },
};
}
return t;
if (!t || !t.name) return t;
const parameters = t.parameters && t.parameters.type === 'object'
? t.parameters
: { type: 'object', properties: (t.parameters && t.parameters.properties) || {} };
return {
type: 'function',
name: t.name,
description: t.description || t.name,
parameters,
};
});
}
@@ -455,6 +467,15 @@ async function complete(opts, onEvent) {
if (run.stats) stats = await run.stats;
} catch (_) {}
}
if (tools && tools.length) {
const recovered = toolParse.recover({ text, thinking, tools, existing: toolCalls });
if (!toolCalls.length && recovered.calls.length) {
for (const call of recovered.calls) toolCalls.push(call);
}
text = recovered.text;
} else {
text = toolParse.stripToolMarkup(text);
}
return {
text,
thinking,
+174
View File
@@ -0,0 +1,174 @@
/**
* Recover Qwen3.5 / Hermes tool calls that QVAC's stream framer misses.
*
* Compact Qwen3.5 models often emit <tool_call> inside <think>…</think>.
* The SDK thinking framer swallows that XML, so the agent never runs a tool.
* No Bare imports — unit-testable on Node.
*/
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>';
const ALIASES = {
search: 'web_search',
websearch: 'web_search',
google: 'google_search',
googlesearch: 'google_search',
fetch: 'web_fetch',
webfetch: 'web_fetch',
fetchpage: 'fetch_page',
open_url: 'fetch_page',
openurl: 'fetch_page',
shell: 'run_terminal_cmd',
bash: 'run_terminal_cmd',
terminal: 'run_terminal_cmd',
cmd: 'run_terminal_cmd',
read: 'read_file',
cat: 'read_file',
write: 'write_file',
ls: 'list_dir',
status: 'jarvis_status',
};
function knownNames(tools) {
const names = new Set();
for (const t of tools || []) {
if (t && t.name) names.add(String(t.name));
if (t && t.function && t.function.name) names.add(String(t.function.name));
}
return names;
}
function remapName(name, names) {
const raw = String(name || '').trim();
if (!raw) return raw;
if (names.has(raw)) return raw;
const folded = raw.toLowerCase().replace(/[\s-]+/g, '_');
if (names.has(folded)) return folded;
const alias = ALIASES[folded];
if (alias && (names.size === 0 || names.has(alias))) return alias;
return raw;
}
function parseJsonArgs(value) {
if (value == null) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
if (typeof value !== 'string') return { value };
const trimmed = value.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
return { value: parsed };
} catch (_) {
return { value: trimmed };
}
}
function parseQwenXmlInner(inner) {
const calls = [];
const fnRe = /<function\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/function>/gi;
let fn;
while ((fn = fnRe.exec(inner)) !== null) {
const args = {};
const paramRe = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/gi;
let pm;
while ((pm = paramRe.exec(fn[2])) !== null) args[pm[1].trim()] = String(pm[2]).trim();
if (!Object.keys(args).length) {
const lineRe = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+?)\s*$/gm;
let line;
while ((line = lineRe.exec(fn[2])) !== null) args[line[1]] = line[2].trim();
}
calls.push({ name: fn[1].trim(), arguments: args });
}
if (calls.length) return calls;
const openFn = /<function\s*=\s*([^>\s]+)\s*>([\s\S]*)$/i.exec(inner);
if (openFn) {
const args = {};
const paramRe = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)(?:<\/parameter>|$)/gi;
let pm;
while ((pm = paramRe.exec(openFn[2])) !== null) args[pm[1].trim()] = String(pm[2]).replace(/<\/parameter>\s*$/i, '').trim();
return [{ name: openFn[1].trim(), arguments: args }];
}
return calls;
}
function parseHermesInner(inner) {
const trimmed = String(inner || '').trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return [];
try {
const parsed = JSON.parse(trimmed);
const list = Array.isArray(parsed) ? parsed : [parsed];
return list
.map((obj) => {
if (!obj || typeof obj !== 'object') return null;
const name = obj.name || (obj.function && obj.function.name);
if (!name) return null;
const args = obj.arguments != null ? obj.arguments : obj.parameters != null ? obj.parameters : obj.args != null ? obj.args : (obj.function && obj.function.arguments);
return { name: String(name), arguments: parseJsonArgs(args) };
})
.filter(Boolean);
} catch (_) {
return [];
}
}
function extractFrames(text) {
const src = String(text || '');
const frames = [];
const re = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
let m;
while ((m = re.exec(src)) !== null) frames.push(m[1]);
if (!frames.length) {
const open = src.search(/<tool_call>/i);
if (open >= 0) frames.push(src.slice(open + '<tool_call>'.length));
}
if (!frames.length && /<function\s*=/i.test(src)) frames.push(src);
return frames;
}
function extractCalls(text, tools) {
const names = knownNames(tools);
const out = [];
const seen = new Set();
for (const frame of extractFrames(text)) {
let parsed = parseQwenXmlInner(frame);
if (!parsed.length) parsed = parseHermesInner(frame);
for (const call of parsed) {
const name = remapName(call.name, names);
const args = call.arguments && typeof call.arguments === 'object' ? call.arguments : {};
const key = name + ':' + JSON.stringify(args);
if (seen.has(key)) continue;
seen.add(key);
out.push({ name, arguments: args });
}
}
return out;
}
function stripToolMarkup(text) {
return String(text || '')
.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
.replace(/<function\s*=[^>]*>[\s\S]*?<\/function>/gi, '')
.replace(/<tool_call>[\s\S]*$/gi, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function recover({ text, thinking, tools, existing } = {}) {
const spoken = stripToolMarkup(text);
const have = Array.isArray(existing) && existing.length;
const calls = have ? existing.slice() : extractCalls([thinking, text].filter(Boolean).join('\n'), tools);
return { calls, text: spoken };
}
module.exports = {
FORMAT_REMINDER,
ALIASES,
extractCalls,
stripToolMarkup,
recover,
remapName,
};