// v1.10.5: XML-tag tool-call fallback. Some models emit // value // in plain content instead of using the OpenAI tool_calls JSON channel. // The streaming loop in stream-phase.ts extracts these blocks via these helpers. // // v1.13.16: also recognize Anthropic // markup. qwen3.6-35b-a3b-mxfp4 drifts to this format when prompted as an // "Architect"-style agent because Claude Code documentation in its // pre-training data uses this shape. Both formats route through the same // synthetic ToolCall path with shared xml_call_${idx} IDs; downstream // dispatch handles unknown tool names with a richer error (see // tool-suggestions.ts + tool-phase.ts). export const XML_TOOL_OPEN = ''; export const XML_TOOL_CLOSE = ''; // v1.13.16: Anthropic opener is matched by prefix (not the full // `` tag) because attributes follow. Closer is the literal tag. export const INVOKE_TOOL_OPEN = '; } // v1.10.5: Qwen-flavor parser. Tightened in v1.13.16 to tolerate whitespace // around `=` (e.g. ``). Name capture is non-whitespace, // non-`>` so a stray space doesn't get absorbed into the function name. const QWEN_FUNCTION_RE = /\s]+)\s*>/; const QWEN_PARAM_RE = /\s]+)\s*>([\s\S]*?)<\/parameter>/g; export function parseXmlToolCall(block: string): ParsedCall | null { const nameMatch = block.match(QWEN_FUNCTION_RE); if (!nameMatch || !nameMatch[1]) return null; const name = nameMatch[1].trim(); if (!name) return null; const args: Record = {}; for (const m of block.matchAll(QWEN_PARAM_RE)) { const key = (m[1] ?? '').trim(); if (!key) continue; const raw = (m[2] ?? '').trim(); try { args[key] = JSON.parse(raw); } catch { args[key] = raw; } } return { name, args }; } // v1.13.16: Anthropic-flavor parser. Same JSON-parse-with-string-fallback // shape as parseXmlToolCall so the dispatch layer doesn't need to care which // flavor produced the call. const INVOKE_NAME_RE = //; const INVOKE_PARAM_RE = /([\s\S]*?)<\/parameter>/g; export function parseInvokeToolCall(block: string): ParsedCall | null { const nameMatch = block.match(INVOKE_NAME_RE); if (!nameMatch) return null; const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim(); if (!name) return null; const args: Record = {}; for (const m of block.matchAll(INVOKE_PARAM_RE)) { const key = ((m[2] ?? m[3] ?? '') as string).trim(); if (!key) continue; const raw = (m[4] ?? '').trim(); try { args[key] = JSON.parse(raw); } catch { args[key] = raw; } } return { name, args }; } // Locate the first character that begins (or completely contains) an // unfinished opener (either flavor) in `s`. Returns -1 when `s` can be // flushed to the client in full without risking a partial tag leak. // Case 1: a full opener (`` or ` ParsedCall | null; } const OPENER_SPECS: ReadonlyArray = [ { open: XML_TOOL_OPEN, close: XML_TOOL_CLOSE, parse: parseXmlToolCall }, { open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall }, ]; export function extractToolCallBlocks(buffer: string): ToolCallExtraction { let flushed = ''; const calls: ParsedCall[] = []; let pos = 0; while (pos < buffer.length) { let next: { spec: OpenerSpec; openIdx: number; closeIdx: number } | null = null; for (const spec of OPENER_SPECS) { const openIdx = buffer.indexOf(spec.open, pos); if (openIdx === -1) continue; const closeIdx = buffer.indexOf(spec.close, openIdx); if (closeIdx === -1) continue; if (next === null || openIdx < next.openIdx) { next = { spec, openIdx, closeIdx }; } } if (next === null) break; if (next.openIdx > pos) { flushed += buffer.slice(pos, next.openIdx); } const blockEnd = next.closeIdx + next.spec.close.length; const block = buffer.slice(next.openIdx, blockEnd); const parsed = next.spec.parse(block); if (parsed) calls.push(parsed); pos = blockEnd; } const tail = buffer.slice(pos); const partialIdx = partialXmlOpenerStart(tail); if (partialIdx === -1) { flushed += tail; return { flushed, calls, remaining: '' }; } if (partialIdx > 0) { flushed += tail.slice(0, partialIdx); } return { flushed, calls, remaining: tail.slice(partialIdx) }; }