/** * Inline tool-call dialect rescue (#231 audit). * * When a conversation switches models mid-task (failover, sticky miss), the * new model often continues the previous model's tool-call style and emits * the call as TEXT in its private training dialect instead of a structured * `tool_calls` array. The client's agent loop sees prose, treats the turn as * a final answer, and dies mid-task — observed live with the OpenAI Agents * SDK when Kimi-K2.6 continued a DeepSeek history: * * <|tool_calls_section_begin|> <|tool_call_begin|> chatcmpl-tool-bde5... * * This module detects the known dialects and re-parses them into standard * OpenAI tool_calls, schema-gated against the request's tool list. A turn * that is detected as a dialect but cannot be parsed into a known tool is a * DEAD turn — the caller fails over instead of delivering gibberish. * * Supported dialects: * 1. Kimi / DeepSeek token style: * <|tool_calls_section_begin|><|tool_call_begin|>functions.NAME:0 * <|tool_call_argument_begin|>{...}<|tool_call_end|>... * 2. Llama / Groq function tags: and * {...} * 3. Qwen / Hermes XML: {"name": ..., "arguments": ...} * 4. Bare or ```json-fenced single JSON object: {"name": KNOWN, "arguments": {...}} * (only rescued when "name" matches a requested tool — bare JSON is a * legitimate answer shape, so this one is strictly schema-gated) */ // Markers that begin an inline dialect block. Used both for full-text // detection and for the streaming hold-window decision in proxy.ts. const DIALECT_MARKERS = [ '<|tool_calls_section_begin|>', '<|tool_call_begin|>', '', ' t.startsWith(m)); } /** * Streaming hold-window helper: could `text` still grow into a dialect * marker? True while text is a strict prefix of some marker (e.g. "<|too"), * so the stream loop keeps holding; once this and startsWithDialectMarker * are both false the text is ordinary prose and can be flushed. */ export function couldBecomeDialectMarker(text) { const t = text.trimStart(); if (t.length === 0) return true; return DIALECT_MARKERS.some(m => m.startsWith(t) && t.length < m.length); } /** Anywhere-in-text detection for the non-streaming path. */ export function containsDialectMarker(text) { return DIALECT_MARKERS.some(m => text.includes(m)); } /** * Extract one balanced JSON object or array starting at text[from] (which * must be '{' or '['). Returns the slice and the index after it, or null. * String-aware so braces inside JSON strings don't break the balance. */ function extractBalancedJson(text, from) { 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, toolNames) => toolNames.size === 0 || toolNames.has(name); /** Parse `{"name": ..., "arguments"|"parameters": ...}` into a call. */ function callFromNamedJson(json, toolNames) { let obj; try { obj = JSON.parse(json); } catch { return null; } if (typeof obj !== 'object' || obj === null) return null; const o = obj; 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 }; } /** Dialect 1: Kimi/DeepSeek <|tool_call_begin|> token blocks. */ function parseTokenDialect(text, toolNames) { const calls = []; let clean = text; // Strip section wrappers first; they carry no information. 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; let parsedAll = true; const spans = []; 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); // Function name rides in the id token as `functions.NAME:IDX`. Some // models degrade it to an opaque id (observed: `chatcmpl-tool-`), // which leaves no way to know WHICH tool was meant — unparseable. 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 { /* fall through */ } 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() }; } /** Dialect 2: (with or without a '>' after the name). */ function parseFunctionTagDialect(text, toolNames) { const calls = []; let clean = text; let parsedAll = true; const headRe = /?\s*/g; let m; const spans = []; 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 { /* fall through */ } if (ok) calls.push({ name, arguments: extracted.json }); } if (!ok) parsedAll = false; // array-shaped or invalid args: not a callable shape const closeTag = text.indexOf('', extracted?.end ?? m.index + m[0].length); spans.push({ from: m.index, to: closeTag === -1 ? (extracted?.end ?? m.index + m[0].length) : closeTag + ''.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() }; } /** Dialect 3: {...} XML-JSON blocks. */ function parseXmlDialect(text, toolNames) { const calls = []; let parsedAll = true; const re = /\s*([\s\S]*?)\s*<\/tool_call>/g; let m; let clean = text; const matches = []; 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, ''); // An opening tag with no close (truncated stream) is detected-but-broken. if (//.test(clean)) { parsedAll = false; clean = clean.replace(/[\s\S]*$/, ''); } return { calls: parsedAll && calls.length > 0 ? calls : null, cleanText: clean.trim() }; } /** * Rescue inline tool-call dialects out of an assistant text answer. * * @param text the assistant message content * @param toolNames the names of the tools the REQUEST declared; rescued * calls must match one (empty set = accept any name, * used by tests only) */ export function rescueInlineToolCalls(text, toolNames) { 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('')) { const { calls, cleanText } = parseXmlDialect(text, toolNames); return { detected: true, calls, cleanText }; } // Dialect 4: the entire answer is one JSON object naming a known tool — // either bare or inside a ```json fence. Strictly schema-gated. 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); // Only treat as dialect when it actually names a requested tool; // arbitrary JSON answers must pass through untouched. if (call) return { detected: true, calls: [call], cleanText: '' }; } return { detected: false, calls: null, cleanText: text }; } //# sourceMappingURL=tool-call-rescue.js.map