204 lines
6.9 KiB
JavaScript
204 lines
6.9 KiB
JavaScript
/**
|
|
* 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, 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',
|
|
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 };
|
|
}
|
|
|
|
// 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,
|
|
extractCalls,
|
|
stripToolMarkup,
|
|
recover,
|
|
remapName,
|
|
qwen35ToolCallXml,
|
|
prepareToolHistory,
|
|
};
|