308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
// @ts-nocheck
|
|
|
|
|
|
function parseToolCallJson(jsonStr, calls, nameHint = '') {
|
|
const raw = String(jsonStr).trim();
|
|
const candidates = [raw, raw.replace(/\{\{/g, '{').replace(/\}\}/g, '}')];
|
|
for (const s of candidates) {
|
|
try {
|
|
const obj = JSON.parse(s);
|
|
if (!obj || typeof obj !== 'object') continue;
|
|
const name = obj.name || nameHint;
|
|
if (!name) continue;
|
|
const args = obj.arguments != null
|
|
? obj.arguments
|
|
: (obj.parameters != null ? obj.parameters : {});
|
|
calls.push({
|
|
id: `toolu_local_${Date.now()}_${calls.length}`,
|
|
name: String(name),
|
|
arguments: typeof args === 'string' ? safeJson(args) : (args || {}),
|
|
});
|
|
return true;
|
|
} catch (_) { }
|
|
}
|
|
console.warn('[tool-call-parse] JSON parse failed:', raw.slice(0, 100));
|
|
return false;
|
|
}
|
|
|
|
function safeJson(s) {
|
|
try { return JSON.parse(s); } catch (_) { return { _raw: s }; }
|
|
}
|
|
|
|
function findBalancedJsonFrom(text, pos = 0) {
|
|
const open = text.indexOf('{', pos);
|
|
if (open === -1) return null;
|
|
let depth = 0;
|
|
let inStr = false;
|
|
let esc = false;
|
|
for (let i = open; i < text.length; i++) {
|
|
const c = text[i];
|
|
if (esc) { esc = false; continue; }
|
|
if (inStr) {
|
|
if (c === '\\') esc = true;
|
|
else if (c === '"') inStr = false;
|
|
continue;
|
|
}
|
|
if (c === '"') { inStr = true; continue; }
|
|
if (c === '{') depth++;
|
|
else if (c === '}') {
|
|
depth--;
|
|
if (depth === 0) {
|
|
return { json: text.slice(open, i + 1), start: open, end: i + 1 };
|
|
}
|
|
}
|
|
}
|
|
return { incomplete: true, start: open };
|
|
}
|
|
|
|
function findLastBalancedJson(text) {
|
|
let best = null;
|
|
let pos = 0;
|
|
while (pos < text.length) {
|
|
const hit = findBalancedJsonFrom(text, pos);
|
|
if (!hit) break;
|
|
if (hit.incomplete) break;
|
|
best = hit;
|
|
pos = hit.end;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function skipWs(text, pos) {
|
|
while (pos < text.length && /\s/.test(text[pos])) pos++;
|
|
return pos;
|
|
}
|
|
|
|
function hasClosingMarker(text, pos, marker) {
|
|
const rest = text.slice(pos).trimStart();
|
|
return rest.toUpperCase().startsWith(marker);
|
|
}
|
|
|
|
const OPEN_TAG_RE = /(?:<\s*\|?\s*tool_calls?\s*\|?\s*>|<\s*tool_calls?\b[^>]*>|<\s*tool_call\b(?=[\s{]))/gi;
|
|
const CLOSE_TAG_RE = /<\s*\/\s*\|?\s*tool_calls?\s*\|?\s*>/gi;
|
|
|
|
function findCloseTag(s, from) {
|
|
CLOSE_TAG_RE.lastIndex = from;
|
|
const m = CLOSE_TAG_RE.exec(s);
|
|
return m ? { index: m.index, end: m.index + m[0].length, text: m[0] } : null;
|
|
}
|
|
|
|
function extractNameHint(inner, jsonStart) {
|
|
const before = inner.slice(0, jsonStart).trim();
|
|
if (!before) return '';
|
|
|
|
const line = before.split(/\n/).map(x => x.trim()).filter(Boolean).pop() || '';
|
|
if (/^[a-z][a-z0-9_]*$/i.test(line)) return line;
|
|
return '';
|
|
}
|
|
|
|
function collectToolCallSpans(buf, { allowOpenMarkers = false } = {}) {
|
|
const spans = [];
|
|
const s = String(buf);
|
|
|
|
OPEN_TAG_RE.lastIndex = 0;
|
|
let m;
|
|
while ((m = OPEN_TAG_RE.exec(s)) !== null) {
|
|
const afterTag = m.index + m[0].length;
|
|
const close = findCloseTag(s, afterTag);
|
|
if (!close) {
|
|
|
|
if (allowOpenMarkers) {
|
|
const hit = findBalancedJsonFrom(s, afterTag);
|
|
if (hit?.json) {
|
|
const nameHint = extractNameHint(s.slice(afterTag, hit.start), hit.start - afterTag);
|
|
spans.push({
|
|
index: m.index,
|
|
end: hit.end,
|
|
json: hit.json,
|
|
nameHint,
|
|
});
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
const inner = s.slice(afterTag, close.index);
|
|
const hit = findBalancedJsonFrom(inner, 0);
|
|
if (hit?.json) {
|
|
spans.push({
|
|
index: m.index,
|
|
end: close.end,
|
|
json: hit.json,
|
|
nameHint: extractNameHint(inner, hit.start),
|
|
});
|
|
} else {
|
|
|
|
spans.push({ index: m.index, end: close.end, json: null, nameHint: '' });
|
|
}
|
|
}
|
|
|
|
|
|
CLOSE_TAG_RE.lastIndex = 0;
|
|
while ((m = CLOSE_TAG_RE.exec(s)) !== null) {
|
|
const before = s.slice(0, m.index);
|
|
|
|
if (spans.some(sp => m.index >= sp.index && m.index < sp.end)) continue;
|
|
|
|
const hit = findLastBalancedJson(before);
|
|
if (!hit?.json) continue;
|
|
|
|
const between = before.slice(hit.end).trim();
|
|
if (between && !/^[a-z][a-z0-9_]*$/i.test(between)) continue;
|
|
const nameHint = between || '';
|
|
|
|
let start = hit.start;
|
|
const head = before.slice(0, hit.start);
|
|
const junk = head.match(/(?:^|\n)[ \t]*([A-Za-z|]{3,24})[ \t]*\n?[ \t]*$/);
|
|
if (junk && /tool|call|ronic|function|invoke|xml/i.test(junk[1])) {
|
|
const idx = head.lastIndexOf(junk[1]);
|
|
if (idx >= 0) start = idx;
|
|
}
|
|
spans.push({ index: start, end: m.index + m[0].length, json: hit.json, nameHint });
|
|
}
|
|
|
|
|
|
let searchFrom = 0;
|
|
while (searchFrom < s.length) {
|
|
const hit = findBalancedJsonFrom(s, searchFrom);
|
|
if (!hit?.json) break;
|
|
if (hit.incomplete) break;
|
|
if (!spans.some(sp => hit.start >= sp.index && hit.start < sp.end)) {
|
|
try {
|
|
const obj = JSON.parse(hit.json);
|
|
const n = obj?.name;
|
|
if (n && /^(profile_|metro_)/.test(n) && (obj.arguments != null || obj.parameters != null)) {
|
|
spans.push({ index: hit.start, end: hit.end, json: hit.json, nameHint: '' });
|
|
}
|
|
} catch (_) { }
|
|
}
|
|
searchFrom = hit.end;
|
|
}
|
|
|
|
for (const marker of ['ICF', 'ICO']) {
|
|
const re = new RegExp(marker, 'gi');
|
|
re.lastIndex = 0;
|
|
while ((m = re.exec(s)) !== null) {
|
|
const afterMarker = skipWs(s, m.index + m[0].length);
|
|
const hit = findBalancedJsonFrom(s, afterMarker);
|
|
if (!hit?.json) continue;
|
|
|
|
let end = hit.end;
|
|
if (hasClosingMarker(s, hit.end, marker)) {
|
|
end = skipWs(s, hit.end) + marker.length;
|
|
} else if (!allowOpenMarkers && marker !== 'ICO') {
|
|
continue;
|
|
} else if (!allowOpenMarkers && marker === 'ICO') {
|
|
end = hit.end;
|
|
}
|
|
|
|
spans.push({ index: m.index, end, json: hit.json, nameHint: '' });
|
|
}
|
|
}
|
|
|
|
spans.sort((a, b) => a.index - b.index);
|
|
const merged = [];
|
|
for (const span of spans) {
|
|
if (merged.length && span.index < merged[merged.length - 1].end) continue;
|
|
merged.push(span);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function consumeSpans(buf, spans, calls) {
|
|
let safeText = '';
|
|
let cursor = 0;
|
|
for (const hit of spans) {
|
|
if (hit.index < cursor) continue;
|
|
safeText += buf.slice(cursor, hit.index);
|
|
if (hit.json) parseToolCallJson(hit.json, calls, hit.nameHint || '');
|
|
cursor = hit.end;
|
|
}
|
|
return { safeText, cursor };
|
|
}
|
|
|
|
export function scanToolCallBuffer(buf) {
|
|
const calls = [];
|
|
const spans = collectToolCallSpans(buf, { allowOpenMarkers: false });
|
|
const { safeText: head, cursor } = consumeSpans(buf, spans, calls);
|
|
|
|
const tail = buf.slice(cursor);
|
|
|
|
|
|
OPEN_TAG_RE.lastIndex = 0;
|
|
let openM = null;
|
|
let tm;
|
|
while ((tm = OPEN_TAG_RE.exec(tail)) !== null) openM = tm;
|
|
if (openM && !findCloseTag(tail, openM.index + openM[0].length)) {
|
|
return {
|
|
safeText: head + tail.slice(0, openM.index),
|
|
remainder: tail.slice(openM.index),
|
|
toolCalls: calls,
|
|
};
|
|
}
|
|
|
|
for (const marker of ['ICF', 'ICO']) {
|
|
const re = new RegExp(`${marker}(?!\\w)`, 'i');
|
|
const idx = tail.search(re);
|
|
if (idx === -1) continue;
|
|
const afterMarker = skipWs(tail, idx + marker.length);
|
|
const hit = findBalancedJsonFrom(tail, afterMarker);
|
|
if (hit?.incomplete) {
|
|
return { safeText: head + tail.slice(0, idx), remainder: tail.slice(idx), toolCalls: calls };
|
|
}
|
|
}
|
|
|
|
|
|
const bare = tail.match(/\{[^{}]*"name"\s*:\s*"(?:profile_|metro_)[^"]*"\s*,\s*"arguments"\s*:\s*\{[^}]*$/);
|
|
if (bare) {
|
|
return {
|
|
safeText: head + tail.slice(0, bare.index),
|
|
remainder: tail.slice(bare.index),
|
|
toolCalls: calls,
|
|
};
|
|
}
|
|
|
|
const partial = tail.match(/(?:<\s*\|?\s*t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l(?:s)?)?)?)?)?)?)?)?)?|IC(?:O|F)?)?$/i);
|
|
if (partial) {
|
|
return { safeText: head + tail.slice(0, partial.index), remainder: tail.slice(partial.index), toolCalls: calls };
|
|
}
|
|
|
|
return { safeText: head + tail, remainder: '', toolCalls: calls };
|
|
}
|
|
|
|
export function finalizeToolCallBuffer(buf) {
|
|
if (!buf) return { safeText: '', toolCalls: [] };
|
|
|
|
const calls = [];
|
|
const stripped = String(buf);
|
|
const spans = collectToolCallSpans(stripped, { allowOpenMarkers: true });
|
|
const { safeText, cursor } = consumeSpans(stripped, spans, calls);
|
|
|
|
const looseText = cleanToolCallArtifacts(safeText + stripped.slice(cursor));
|
|
return { safeText: looseText, toolCalls: calls };
|
|
}
|
|
|
|
export function cleanToolCallArtifacts(fullText) {
|
|
let s = String(fullText || '');
|
|
const spans = collectToolCallSpans(s, { allowOpenMarkers: true });
|
|
if (spans.length) {
|
|
let out = '';
|
|
let cursor = 0;
|
|
for (const hit of spans) {
|
|
if (hit.index < cursor) continue;
|
|
out += s.slice(cursor, hit.index);
|
|
cursor = hit.end;
|
|
}
|
|
out += s.slice(cursor);
|
|
s = out;
|
|
}
|
|
|
|
return s
|
|
.replace(/<\s*\|?\s*\/?\s*tool_calls?\s*\|?\s*>/gi, '')
|
|
.replace(/<\/?tool_call>/gi, '')
|
|
.replace(/(?:^|\n)\s*(?:ICO|ICF)\s*(?=\n|\{|$)/gi, '\n')
|
|
|
|
.replace(/(?:^|\n)\s*[A-Za-z|]{0,24}\s*\n?\s*\{[^{}]*"name"\s*:\s*"(?:profile_|metro_)[^"]*"[\s\S]*?\}\s*(?:\n\s*<\s*\/\s*\|?\s*tool_calls?\s*\|?\s*>)?/gi, '\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
}
|