LFM2.5-VL-3B-WebGPU / src /tools /tool-protocol.js
shubeydoo's picture
Initial release
6c30253
Raw
History Blame Contribute Delete
6.02 kB
export const TOOL_CALL_START = '<|tool_call_start|>';
export const TOOL_CALL_END = '<|tool_call_end|>';
const CONTROL_TOKENS = /<\|(?:im_start|im_end|tool_call_start|tool_call_end)\|>/g;
export function parseToolCalls(rawText) {
const calls = [];
let cursor = 0;
while (cursor < rawText.length) {
const start = rawText.indexOf(TOOL_CALL_START, cursor);
if (start < 0) break;
const contentStart = start + TOOL_CALL_START.length;
const end = rawText.indexOf(TOOL_CALL_END, contentStart);
if (end < 0) throw new Error('The model emitted an incomplete tool call.');
calls.push(...parseCallList(rawText.slice(contentStart, end)));
cursor = end + TOOL_CALL_END.length;
}
return calls;
}
export function displayTextFromRaw(rawText) {
let output = '';
let cursor = 0;
while (cursor < rawText.length) {
const start = rawText.indexOf(TOOL_CALL_START, cursor);
if (start < 0) {
output += rawText.slice(cursor);
break;
}
output += rawText.slice(cursor, start);
const end = rawText.indexOf(TOOL_CALL_END, start + TOOL_CALL_START.length);
if (end < 0) break;
cursor = end + TOOL_CALL_END.length;
}
return output.replace(CONTROL_TOKENS, '').replace(/^assistant\s*\n/i, '').trim();
}
export function createToolStreamFilter(onText, { onToolCallStart, onToolCallEnd } = {}) {
let buffer = '';
let insideCall = false;
const reserve = Math.max(TOOL_CALL_START.length, TOOL_CALL_END.length) - 1;
function flush(force = false) {
while (buffer) {
const marker = insideCall ? TOOL_CALL_END : TOOL_CALL_START;
const markerIndex = buffer.indexOf(marker);
if (markerIndex >= 0) {
if (!insideCall && markerIndex > 0) emit(buffer.slice(0, markerIndex));
buffer = buffer.slice(markerIndex + marker.length);
insideCall = !insideCall;
if (insideCall) onToolCallStart?.();
else onToolCallEnd?.();
continue;
}
const length = force ? buffer.length : Math.max(0, buffer.length - reserve);
if (!length) break;
if (!insideCall) emit(buffer.slice(0, length));
buffer = buffer.slice(length);
}
}
function emit(value) {
const clean = value.replace(CONTROL_TOKENS, '');
if (clean) onText(clean);
}
return {
push(chunk) { buffer += chunk; flush(false); },
finish() { flush(true); },
};
}
function parseCallList(source) {
const trimmed = source.trim();
const body = trimmed.startsWith('[') && trimmed.endsWith(']')
? trimmed.slice(1, -1)
: trimmed;
if (!body.trim()) return [];
return splitTopLevel(body).map(parseCall);
}
function parseCall(source) {
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*)\)$/.exec(source.trim());
if (!match) throw new Error(`Malformed tool call: ${source.trim().slice(0, 80)}`);
const args = {};
const positional = [];
for (const part of splitTopLevel(match[2])) {
const assignment = findTopLevelAssignment(part);
if (assignment < 0) positional.push(parsePythonValue(part));
else {
const key = part.slice(0, assignment).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid argument name: ${key}`);
if (Object.hasOwn(args, key)) throw new Error(`Duplicate argument: ${key}`);
args[key] = parsePythonValue(part.slice(assignment + 1));
}
}
return { name: match[1], arguments: args, positional };
}
function splitTopLevel(source) {
if (!source.trim()) return [];
const parts = [];
let start = 0;
let quote = '';
let escaped = false;
const stack = [];
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === '\\') escaped = true;
else if (character === quote) quote = '';
continue;
}
if (character === '"' || character === "'") { quote = character; continue; }
if ('([{'.includes(character)) stack.push(character);
else if (')]}'.includes(character)) stack.pop();
else if (character === ',' && stack.length === 0) {
parts.push(source.slice(start, index).trim());
start = index + 1;
}
}
if (quote || stack.length) throw new Error('Unbalanced tool-call arguments.');
const finalPart = source.slice(start).trim();
if (finalPart) parts.push(finalPart);
return parts;
}
function findTopLevelAssignment(source) {
let quote = '';
let escaped = false;
let depth = 0;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === '\\') escaped = true;
else if (character === quote) quote = '';
} else if (character === '"' || character === "'") quote = character;
else if ('([{'.includes(character)) depth += 1;
else if (')]}'.includes(character)) depth -= 1;
else if (character === '=' && depth === 0) return index;
}
return -1;
}
function parsePythonValue(source) {
const value = source.trim();
if (!value) throw new Error('Missing tool argument value.');
if (value === 'True' || value === 'true') return true;
if (value === 'False' || value === 'false') return false;
if (value === 'None' || value === 'null') return null;
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return Number(value);
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
if (value[0] === '"') return JSON.parse(value);
return value.slice(1, -1).replace(/\\(['\\nrt])/g, (_, escaped) => ({ "'": "'", '\\': '\\', n: '\n', r: '\r', t: '\t' })[escaped]);
}
if (value.startsWith('[') && value.endsWith(']')) return splitTopLevel(value.slice(1, -1)).map(parsePythonValue);
if (value.startsWith('{') && value.endsWith('}')) {
try { return JSON.parse(value); } catch { throw new Error('Object arguments must use valid JSON.'); }
}
throw new Error(`Unsupported tool argument value: ${value.slice(0, 80)}`);
}