Pure file moves. No behavior change. inference.ts retains createInferenceRunner public surface; new files are internal to services/inference/. - budget.ts: resolveToolBudget - sentinels.ts: detectDoomLoop (re-exported through inference.ts), isCapHitSentinel, isDoomLoopSentinel, isAnySentinel - xml-parser.ts: parseXmlToolCall, partialXmlOpenerStart First of four refactor batches preparing inference.ts for the v1.13 AI SDK migration. inference.ts goes from 1780 LoC to ~1620. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
54 lines
2.2 KiB
TypeScript
54 lines
2.2 KiB
TypeScript
// v1.10.5: XML-tag tool-call fallback. Some models emit
|
|
// <tool_call><function=foo><parameter=key>value</parameter></function></tool_call>
|
|
// in plain content instead of using the OpenAI tool_calls JSON channel.
|
|
// The streaming loop in inference.ts extracts these blocks via these helpers.
|
|
|
|
export const XML_TOOL_OPEN = '<tool_call>';
|
|
export const XML_TOOL_CLOSE = '</tool_call>';
|
|
|
|
export function parseXmlToolCall(
|
|
block: string,
|
|
): { name: string; args: Record<string, unknown> } | null {
|
|
const nameMatch = block.match(/<function=([^>]+)>/);
|
|
if (!nameMatch || !nameMatch[1]) return null;
|
|
const name = nameMatch[1].trim();
|
|
if (!name) return null;
|
|
const args: Record<string, unknown> = {};
|
|
// Non-greedy body so each <parameter=…>…</parameter> pair is matched
|
|
// independently even when multiple appear in the same block.
|
|
const paramRe = /<parameter=([^>]+)>([\s\S]*?)<\/parameter>/g;
|
|
for (const m of block.matchAll(paramRe)) {
|
|
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 };
|
|
}
|
|
|
|
// Locate the first character that begins (or completely contains) an
|
|
// unfinished <tool_call> opener in `s`. Returns -1 when `s` can be flushed
|
|
// to the client in full without risking a partial tag leak.
|
|
// Case 1: a full `<tool_call>` opener with no matching closer — caller
|
|
// must keep everything from that index forward until the next
|
|
// chunk arrives with the closer.
|
|
// Case 2: `s` ends with a strict prefix of `<tool_call>` (e.g. `<tool_c`).
|
|
// Caller must keep just that suffix in the buffer.
|
|
// Note: case 1 assumes the calling loop already extracted every complete
|
|
// <tool_call>…</tool_call> pair before reaching this check.
|
|
export function partialXmlOpenerStart(s: string): number {
|
|
const fullOpener = s.indexOf(XML_TOOL_OPEN);
|
|
if (fullOpener !== -1) return fullOpener;
|
|
const lastLt = s.lastIndexOf('<');
|
|
if (lastLt === -1) return -1;
|
|
const suffix = s.slice(lastLt);
|
|
if (XML_TOOL_OPEN.startsWith(suffix) && suffix.length < XML_TOOL_OPEN.length) {
|
|
return lastLt;
|
|
}
|
|
return -1;
|
|
}
|