| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| export interface RescuedToolCall {
|
| name: string;
|
|
|
| arguments: string;
|
| }
|
|
|
| export interface RescueResult {
|
|
|
| detected: boolean;
|
|
|
| calls: RescuedToolCall[] | null;
|
|
|
| cleanText: string;
|
| }
|
|
|
|
|
|
|
| const DIALECT_MARKERS = [
|
| '<|tool_calls_section_begin|>',
|
| '<|tool_call_begin|>',
|
| '<tool_call>',
|
| '<function=',
|
| ] as const;
|
|
|
|
|
| export function startsWithDialectMarker(text: string): boolean {
|
| const t = text.trimStart();
|
| return DIALECT_MARKERS.some(m => t.startsWith(m));
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| export function couldBecomeDialectMarker(text: string): boolean {
|
| const t = text.trimStart();
|
| if (t.length === 0) return true;
|
| return DIALECT_MARKERS.some(m => m.startsWith(t) && t.length < m.length);
|
| }
|
|
|
|
|
| export function containsDialectMarker(text: string): boolean {
|
| return DIALECT_MARKERS.some(m => text.includes(m));
|
| }
|
|
|
| |
| |
| |
| |
|
|
| function extractBalancedJson(text: string, from: number): { json: string; end: number } | null {
|
| const open = text[from];
|
| if (open !== '{' && open !== '[') return null;
|
| const close = open === '{' ? '}' : ']';
|
| let depth = 0;
|
| let inString = false;
|
| let escaped = false;
|
| for (let i = from; i < text.length; i++) {
|
| const ch = text[i];
|
| if (inString) {
|
| if (escaped) escaped = false;
|
| else if (ch === '\\') escaped = true;
|
| else if (ch === '"') inString = false;
|
| continue;
|
| }
|
| if (ch === '"') inString = true;
|
| else if (ch === open) depth++;
|
| else if (ch === close) {
|
| depth--;
|
| if (depth === 0) return { json: text.slice(from, i + 1), end: i + 1 };
|
| }
|
| }
|
| return null;
|
| }
|
|
|
| const isKnownTool = (name: string, toolNames: Set<string>): boolean =>
|
| toolNames.size === 0 || toolNames.has(name);
|
|
|
|
|
| function callFromNamedJson(json: string, toolNames: Set<string>): RescuedToolCall | null {
|
| let obj: unknown;
|
| try { obj = JSON.parse(json); } catch { return null; }
|
| if (typeof obj !== 'object' || obj === null) return null;
|
| const o = obj as Record<string, unknown>;
|
| const name = typeof o.name === 'string' ? o.name : undefined;
|
| if (!name || !isKnownTool(name, toolNames)) return null;
|
| const rawArgs = o.arguments ?? o.parameters ?? {};
|
| const args = typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs);
|
| try { JSON.parse(args); } catch { return null; }
|
| return { name, arguments: args };
|
| }
|
|
|
|
|
| function parseTokenDialect(text: string, toolNames: Set<string>): { calls: RescuedToolCall[] | null; cleanText: string } {
|
| const calls: RescuedToolCall[] = [];
|
| let clean = text;
|
|
|
| clean = clean.replaceAll('<|tool_calls_section_begin|>', '').replaceAll('<|tool_calls_section_end|>', '');
|
|
|
| const callRe = /<\|tool_call_begin\|>\s*([\s\S]*?)\s*<\|tool_call_argument_begin\|>\s*/g;
|
| let m: RegExpExecArray | null;
|
| let parsedAll = true;
|
| const spans: Array<{ from: number; to: number }> = [];
|
| while ((m = callRe.exec(clean)) !== null) {
|
| const idToken = m[1].trim();
|
| const argStart = m.index + m[0].length;
|
| const jsonStart = clean.indexOf('{', argStart);
|
| const extracted = jsonStart === -1 ? null : extractBalancedJson(clean, jsonStart);
|
|
|
|
|
|
|
| const nameMatch = /^functions\.([A-Za-z0-9_.-]+):\d+$/.exec(idToken);
|
| const name = nameMatch?.[1];
|
| let argsOk = false;
|
| if (extracted && name && isKnownTool(name, toolNames)) {
|
| try { JSON.parse(extracted.json); argsOk = true; } catch { }
|
| if (argsOk) calls.push({ name, arguments: extracted.json });
|
| }
|
| if (!argsOk) parsedAll = false;
|
| const endTag = clean.indexOf('<|tool_call_end|>', extracted?.end ?? argStart);
|
| spans.push({ from: m.index, to: endTag === -1 ? (extracted?.end ?? argStart) : endTag + '<|tool_call_end|>'.length });
|
| }
|
| for (const s of [...spans].reverse()) clean = clean.slice(0, s.from) + clean.slice(s.to);
|
| return { calls: parsedAll && calls.length > 0 ? calls : null, cleanText: clean.trim() };
|
| }
|
|
|
|
|
| function parseFunctionTagDialect(text: string, toolNames: Set<string>): { calls: RescuedToolCall[] | null; cleanText: string } {
|
| const calls: RescuedToolCall[] = [];
|
| let clean = text;
|
| let parsedAll = true;
|
| const headRe = /<function=([A-Za-z0-9_.-]+)\s*>?\s*/g;
|
| let m: RegExpExecArray | null;
|
| const spans: Array<{ from: number; to: number }> = [];
|
| while ((m = headRe.exec(text)) !== null) {
|
| const name = m[1];
|
| const afterHead = m.index + m[0].length;
|
| const jsonStart = text[afterHead] === '{' || text[afterHead] === '['
|
| ? afterHead
|
| : text.indexOf('{', afterHead);
|
| const extracted = jsonStart === -1 ? null : extractBalancedJson(text, jsonStart);
|
| let ok = false;
|
| if (extracted && isKnownTool(name, toolNames) && extracted.json.startsWith('{')) {
|
| try { JSON.parse(extracted.json); ok = true; } catch { }
|
| if (ok) calls.push({ name, arguments: extracted.json });
|
| }
|
| if (!ok) parsedAll = false;
|
| const closeTag = text.indexOf('</function>', extracted?.end ?? m.index + m[0].length);
|
| spans.push({ from: m.index, to: closeTag === -1 ? (extracted?.end ?? m.index + m[0].length) : closeTag + '</function>'.length });
|
| }
|
| for (const s of [...spans].reverse()) clean = clean.slice(0, s.from) + clean.slice(s.to);
|
| return { calls: parsedAll && calls.length > 0 ? calls : null, cleanText: clean.trim() };
|
| }
|
|
|
|
|
| function parseXmlDialect(text: string, toolNames: Set<string>): { calls: RescuedToolCall[] | null; cleanText: string } {
|
| const calls: RescuedToolCall[] = [];
|
| let parsedAll = true;
|
| const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
| let m: RegExpExecArray | null;
|
| let clean = text;
|
| const matches: string[] = [];
|
| while ((m = re.exec(text)) !== null) matches.push(m[1]);
|
| for (const inner of matches) {
|
| const call = callFromNamedJson(inner, toolNames);
|
| if (call) calls.push(call);
|
| else parsedAll = false;
|
| }
|
| clean = clean.replace(re, '');
|
|
|
| if (/<tool_call>/.test(clean)) { parsedAll = false; clean = clean.replace(/<tool_call>[\s\S]*$/, ''); }
|
| return { calls: parsedAll && calls.length > 0 ? calls : null, cleanText: clean.trim() };
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| export function rescueInlineToolCalls(text: string, toolNames: Set<string>): RescueResult {
|
| if (!text) return { detected: false, calls: null, cleanText: text };
|
|
|
| if (text.includes('<|tool_call_begin|>') || text.includes('<|tool_calls_section_begin|>')) {
|
| const { calls, cleanText } = parseTokenDialect(text, toolNames);
|
| return { detected: true, calls, cleanText };
|
| }
|
| if (text.includes('<function=')) {
|
| const { calls, cleanText } = parseFunctionTagDialect(text, toolNames);
|
| return { detected: true, calls, cleanText };
|
| }
|
| if (text.includes('<tool_call>')) {
|
| const { calls, cleanText } = parseXmlDialect(text, toolNames);
|
| return { detected: true, calls, cleanText };
|
| }
|
|
|
|
|
|
|
| const trimmed = text.trim();
|
| const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(trimmed);
|
| const candidate = (fenced ? fenced[1] : trimmed).trim();
|
| if (candidate.startsWith('{') && candidate.endsWith('}')) {
|
| const call = callFromNamedJson(candidate, toolNames);
|
|
|
|
|
| if (call) return { detected: true, calls: [call], cleanText: '' };
|
| }
|
|
|
| return { detected: false, calls: null, cleanText: text };
|
| }
|
|
|