// 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) };
}