/** * Recover Qwen3.5 / Hermes tool calls that QVAC's stream framer misses. * * Compact Qwen3.5 models often emit inside . * The SDK thinking framer swallows that XML, so the agent never runs a tool. * No Bare imports — unit-testable on Node. */ const webProcess = require('./web-process.js'); const FORMAT_REMINDER = 'When you need a tool, emit only this XML (not JSON, not a question, not a spoken plan):\n' + '\n\n\nvalue\n\n\n'; 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', click: 'cu_click', type: 'cu_type', type_text: 'cu_type', observe: 'cu_observe', look: 'cu_observe', screenshot: 'cu_observe', find: 'cu_find', camera: 'webcam', webcam: 'webcam', hover: 'cu_hover', scroll: 'cu_scroll', key: 'cu_key', press: 'cu_key', }; 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; if (webProcess.isRetiredWeb(folded) && names.has('browser') && !names.has(webProcess.canonicalWeb(folded))) { return 'browser'; } 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 = /\s]+)\s*>([\s\S]*?)<\/function>/gi; let fn; while ((fn = fnRe.exec(inner)) !== null) { const args = {}; const paramRe = /\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 = /\s]+)\s*>([\s\S]*)$/i.exec(inner); if (openFn) { const args = {}; const paramRe = /\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 = /([\s\S]*?)<\/tool_call>/gi; let m; while ((m = re.exec(src)) !== null) frames.push(m[1]); if (!frames.length) { const open = src.search(//i); if (open >= 0) frames.push(src.slice(open + ''.length)); } if (!frames.length && /[\s\S]*?<\/tool_call>/gi, '') .replace(/]*>[\s\S]*?<\/function>/gi, '') .replace(/[\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]) => '\n' + (typeof value === 'string' ? value : JSON.stringify(value)) + '\n' ) .join('\n'); return '\n\n' + (params ? params + '\n' : '') + '\n'; } 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 (//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, };