diff --git a/src/services/api/openaiShim.ollamaTextToolCalls.test.ts b/src/services/api/openaiShim.ollamaTextToolCalls.test.ts index 4d8faec0b..35c5eb8ea 100644 --- a/src/services/api/openaiShim.ollamaTextToolCalls.test.ts +++ b/src/services/api/openaiShim.ollamaTextToolCalls.test.ts @@ -658,3 +658,207 @@ describe('Ollama streaming — non-stop terminal finish reasons flush buffer', ( expect(delta?.stop_reason).not.toBe('tool_use') }) }) + +describe('Ollama streaming — -wrapped JSON text fallback (#2040)', () => { + let originalFetch: FetchType + let restoreEnv: () => void + beforeEach(() => { + originalFetch = globalThis.fetch + restoreEnv = snapshotEnv(ENV_KEYS) + process.env.OLLAMA_BASE_URL = 'http://localhost:11434' + process.env.OPENAI_API_KEY = 'test-key' + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + }) + afterEach(() => { + globalThis.fetch = originalFetch + restoreEnv() + }) + + test('Qwen-style {json} text becomes a tool_use block', async () => { + // Repro: Ollama returns the model's template-format tool call (prose then + // {json}) in message.content, not message.tool_calls. + globalThis.fetch = (async () => + makeOllamaNativeStreamingResponse( + makeNdjsonChunks([ + ollamaChunk('I will build the project.\n\n'), + ollamaChunk('{"name": "Bash", "arguments": {"command": "npm run build"}}\n'), + ollamaChunk(''), + ollamaChunk('', 'stop'), + ]), + )) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'qwen2.5-coder:14b', + messages: [{ role: 'user', content: 'build the project' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Record[] = [] + for await (const event of result.data) events.push(event) + + const allText = events + .filter(e => e.type === 'content_block_delta' && (e.delta as Record)?.type === 'text_delta') + .map(e => (e.delta as Record).text) + .join('') + expect(allText).toBe('I will build the project.') + + const toolStarts = events.filter( + e => e.type === 'content_block_start' && (e.content_block as Record)?.type === 'tool_use', + ) + expect(toolStarts).toHaveLength(1) + expect((toolStarts[0].content_block as Record).name).toBe('Bash') + const toolInput = events + .filter(e => e.type === 'content_block_delta' && (e.delta as Record)?.type === 'input_json_delta') + .map(e => (e.delta as Record).partial_json) + .join('') + expect(JSON.parse(toolInput)).toEqual({ command: 'npm run build' }) + + const messageDelta = events.find(e => e.type === 'message_delta') as Record | undefined + expect((messageDelta?.delta as Record)?.stop_reason).toBe('tool_use') + }) + + async function streamOllamaContent(content: string) { + globalThis.fetch = (async () => + makeOllamaNativeStreamingResponse( + makeNdjsonChunks([ollamaChunk(content), ollamaChunk('', 'stop')]), + )) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'qwen2.5-coder:14b', + messages: [{ role: 'user', content: 'go' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Record[] = [] + for await (const event of result.data) events.push(event) + + const deltas = events + .filter(e => e.type === 'content_block_delta') + .map(e => e.delta as Record) + const messageDelta = events.find(e => e.type === 'message_delta') as Record | undefined + return { + text: deltas.filter(d => d.type === 'text_delta').map(d => d.text).join(''), + tools: events + .filter(e => e.type === 'content_block_start' && (e.content_block as Record)?.type === 'tool_use') + .map(e => (e.content_block as Record).name), + inputs: deltas.filter(d => d.type === 'input_json_delta').map(d => JSON.parse(d.partial_json)), + stopReason: (messageDelta?.delta as Record)?.stop_reason, + } + } + + const xmlCall = (command: string) => + `{"name":"Bash","arguments":{"command":"${command}"}}` + + test.each([ + ['closed', `I could call ${xmlCall('echo speculative')} but should ask first.Please confirm the operation.`], + ['unterminated', `Please confirm the operation. I could call ${xmlCall('echo speculative')}`], + ])(' inside %s reasoning is not recovered as a tool call', async (_kind, content) => { + const { text, tools, stopReason } = await streamOllamaContent(content) + expect(text.trim()).toBe('Please confirm the operation.') + expect(tools).toEqual([]) + expect(stopReason).toBe('end_turn') + }) + + test('only the visible is recovered when reasoning also contains one', async () => { + const { text, tools, inputs, stopReason } = await streamOllamaContent( + `${xmlCall('echo private')}${xmlCall('echo public')}`, + ) + expect(text).toBe('') + expect(tools).toEqual(['Bash']) + expect(inputs).toEqual([{ command: 'echo public' }]) + expect(stopReason).toBe('tool_use') + }) + + test('think tags inside a visible call\'s arguments are kept verbatim', async () => { + const args = { file_path: 'notes.md', content: 'a keep b' } + const { text, tools, inputs, stopReason } = await streamOllamaContent( + `planSaving.\n${JSON.stringify({ name: 'Write', arguments: args })}`, + ) + expect(text).toBe('Saving.') + expect(tools).toEqual(['Write']) + expect(inputs).toEqual([args]) + expect(stopReason).toBe('tool_use') + }) + + test('XML-looking text inside JSON arguments does not change the recovered tool', async () => { + const command = "printf '%s' 'README.md'" + const { text, tools, inputs, stopReason } = await streamOllamaContent( + `Printing markup.\n${JSON.stringify({ name: 'Bash', arguments: { command } })}`, + ) + expect(text).toBe('Printing markup.') + expect(tools).toEqual(['Bash']) + expect(inputs).toEqual([{ command }]) + expect(stopReason).toBe('tool_use') + }) + + test('a closing tag inside a labelled JSON envelope does not change the recovered tool', async () => { + const args = { + file_path: 'a.md', + content: 'xecho leakedy', + } + const { text, tools, inputs, stopReason } = await streamOllamaContent( + `Saving.\njson\n${JSON.stringify({ name: 'Write', arguments: args })}`, + ) + expect(text).toBe('Saving.') + expect(tools).toEqual(['Write']) + expect(inputs).toEqual([args]) + expect(stopReason).toBe('tool_use') + }) + + const nestedBash = 'echo leaked' + + test.each([ + [ + 'a hidden call argument contains a reasoning close tag', + `${JSON.stringify({ name: 'Write', arguments: { file_path: 'a.md', content: `${nestedBash}` } })}Please confirm.`, + ], + [ + 'a visible opener without a body reaches into reasoning', + `I'll use a block. Maybe {"name":"Bash","arguments":{"command":"echo leaked"}} Please confirm.`, + ], + [ + 'an unterminated visible call is followed by reasoning', + 'Listing.\n\n\n\nls\n\n\nelse echo leaked', + ], + [ + 'an opener mentioned in reasoning runs into a visible call', + `I'll emit a now.\n${JSON.stringify({ name: 'Write', arguments: { file_path: 'n.md', content: 'a keep b' } })}`, + ], + [ + 'a reasoning close tag is split around a hidden call', + `plan ${JSON.stringify({ name: 'Write', arguments: { file_path: 'a.md', content: 'x' } })}think>${xmlCall('echo leaked')}Please confirm.`, + ], + [ + 'a hidden block that is not a named call contains a reasoning close tag', + `${JSON.stringify({ type: 'function', function: { name: 'Write', arguments: { content: '' } } })}${nestedBash}Please confirm.`, + ], + [ + 'a reasoning open tag runs into the call opener', + `Please confirm.`, + ], + [ + 'an unclosed reasoning tag containing `<` runs into the call opener', + `Let me check.\n${xmlCall('echo private')}{"name":"Bash","arguments":{"command":"echo "x; echo leaked"}}`, + ], + [ + 'reasoning markup splits a visible call\'s function tag', + `Running.\n${xmlCall('ls')}\nx=Write>a keep b`, + ], + ])('no tool call is recovered when %s', async (_case, content) => { + const { tools, stopReason } = await streamOllamaContent(content) + expect(tools).toEqual([]) + expect(stopReason).toBe('end_turn') + }) +}) diff --git a/src/services/api/openaiShim/streamConversion.ts b/src/services/api/openaiShim/streamConversion.ts index ec89c768d..82cd98595 100644 --- a/src/services/api/openaiShim/streamConversion.ts +++ b/src/services/api/openaiShim/streamConversion.ts @@ -17,6 +17,10 @@ import { processStreamChunk, } from '../../../utils/streamingOptimizer.js' import type { createProviderStreamTrace } from './streamControl.js' +import { + findXmlToolCallBlockRanges, + xmlToolCallBlockMarkup, +} from './xmlToolCallParsing.js' type ParsedRawToolCall = { id: string; name: string; argumentsJson: string } type ParsedTextToolCall = { id: string; name: string; arguments: unknown } @@ -99,6 +103,60 @@ const JSON_REPAIR_SUFFIXES = [ '}', '"}', ']}', '"]}', '}}', '"}}', ']}}', '"]}}', '"]}]}', '}]}', ] +// Fed to the think filter at each block boundary. It cannot continue a tag, so +// a partial tag held before a block never joins the text after it. +const TOOL_CALL_BLOCK_BOUNDARY = '\u0000' + +/** + * Whether reasoning markup touches the tool-call block at `start`: a reasoning + * tag inside the block (outside a JSON envelope's object, whose tag-like text + * is data), or a tag that runs from the preceding text into the block's opener + * (a tag ends at the first `>`, so any tag in the text after the last `>` + * before the block ends at its opener). Either way the block's range may cross + * a reasoning boundary. + */ +function reasoningMarkupTouchesBlock(text: string, start: number, block: string): boolean { + const markup = xmlToolCallBlockMarkup(block) + if (stripThinkTags(markup) !== markup) return true + const lastTagEnd = start > 0 ? text.lastIndexOf('>', start - 1) : -1 + const openerTail = text.slice(lastTagEnd + 1, text.indexOf('>', start) + 1) + return stripThinkTags(openerTail) !== openerTail +} + +/** + * Applies the streaming think filter to `text` but treats each tool-call block + * as one unit that never reaches the filter: blocks that open in visible text + * are copied verbatim, so think tags inside their JSON arguments survive, and + * blocks that open inside reasoning are dropped, so nothing inside them can end + * the reasoning. Returns null when reasoning markup touches a block or blocks + * partially overlap: no call parsed from such a block can be attributed to + * visible text. + */ +function visibleTextKeepingToolCallBlocks( + text: string, + blockRanges: Array<[number, number]>, +): string | null { + const filter = createThinkTagFilter() + let visible = '' + let position = 0 + for (const [start, end] of blockRanges) { + if (start < position) { + if (end > position) return null + continue + } + const block = text.slice(start, end) + if (reasoningMarkupTouchesBlock(text, start, block)) return null + const before = filter.feed(text.slice(position, start) + TOOL_CALL_BLOCK_BOUNDARY) + if (filter.isInsideBlock()) { + visible += before + } else { + visible += before.slice(0, -1) + block + } + position = end + } + return visible + filter.feed(text.slice(position)) +} + export function convertOpenAIStreamUsage( usage: Record | undefined, ): Partial | undefined { @@ -800,12 +858,46 @@ export async function* openaiStreamToAnthropic( const originalFinishReason = choice.finish_reason let ollamaClosedContentBlock = false if (isTerminalOllamaFinish) { - const { calls: textToolCalls, toolCallRanges } = parseTextToolCalls(accumulatedText) + let toolCallSource = accumulatedText + let { calls: textToolCalls, toolCallRanges } = parseTextToolCalls(accumulatedText) + // Qwen-family templates wrap tool calls in `` tags, which + // Ollama can leave unparsed in message.content (#2040). Only blocks + // that open in visible text count: re-parse without reasoning so + // markup inside it never becomes an executable call, and recover + // nothing when a block crosses a reasoning boundary. Removing + // reasoning may only drop calls: a re-parsed call that the raw text + // did not contain was spliced across reasoning, so recover nothing. + if (textToolCalls.length === 0) { + ;({ calls: textToolCalls, toolCallRanges } = parseXmlToolCalls( + accumulatedText, + allowHy3ToolCalls, + )) + const visibleSource = textToolCalls.length > 0 + ? visibleTextKeepingToolCallBlocks( + accumulatedText, + findXmlToolCallBlockRanges(accumulatedText, allowHy3ToolCalls), + ) + : accumulatedText + const callKey = (tc: ParsedTextToolCall) => + `${tc.name}\0${JSON.stringify(tc.arguments)}` + const rawCallKeys = new Set(textToolCalls.map(callKey)) + if (visibleSource !== null && visibleSource !== accumulatedText) { + toolCallSource = visibleSource + ;({ calls: textToolCalls, toolCallRanges } = parseXmlToolCalls( + toolCallSource, + allowHy3ToolCalls, + )) + } + if (visibleSource === null || textToolCalls.some(tc => !rawCallKeys.has(callKey(tc)))) { + textToolCalls = [] + toolCallRanges = [] + } + } if (textToolCalls.length > 0) { ollamaClosedContentBlock = true // Compute visible prose (tool-call JSON stripped, think-tags removed). - // Use accumulatedText (raw) as source because toolCallRanges are relative to it. - const stripped = stripRanges(accumulatedText, toolCallRanges).trim() + // toolCallRanges are relative to the text the calls were parsed from. + const stripped = stripRanges(toolCallSource, toolCallRanges).trim() const strippedVisible = stripThinkTags(stripped).trim() if (hasEmittedContentStart) { // Text block was already open — emit stripped prose then close it. diff --git a/src/services/api/openaiShim/xmlToolCallParsing.test.ts b/src/services/api/openaiShim/xmlToolCallParsing.test.ts index 15418f080..26d3a6fbb 100644 --- a/src/services/api/openaiShim/xmlToolCallParsing.test.ts +++ b/src/services/api/openaiShim/xmlToolCallParsing.test.ts @@ -138,6 +138,48 @@ describe('parseXmlToolCalls', () => { expect(calls[0].arguments).toEqual({ command: 'pwd' }) }) + const xmlLookingCommand = + "printf '%s' 'README.md'" + test.each([ + ['object', { command: xmlLookingCommand }], + ['JSON string', JSON.stringify({ command: xmlLookingCommand })], + ])('dialect C: XML-looking text in %s arguments stays data', (_encoding, args) => { + const text = `${JSON.stringify({ name: 'Bash', arguments: args })}` + const { calls, toolCallRanges } = parseXmlToolCalls(text) + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe('Bash') + expect(calls[0].arguments).toEqual({ command: xmlLookingCommand }) + expect(toolCallRanges).toEqual([[0, text.length]]) + }) + + test('dialect C: a closing tag inside a JSON string does not end the block', () => { + const content = + 'xrm -rf buildy' + const text = `${JSON.stringify({ name: 'Write', arguments: { file_path: 'a.md', content } })} done` + const { calls, toolCallRanges } = parseXmlToolCalls(text) + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe('Write') + expect(calls[0].arguments).toEqual({ file_path: 'a.md', content }) + expect(toolCallRanges).toEqual([[0, text.length - ' done'.length]]) + }) + + test.each([ + ['a `json` label', (json: string) => `json\n${json}`], + ['a Markdown fence', (json: string) => `\n\`\`\`json\n${json}\n\`\`\`\n`], + ])('dialect C: a JSON envelope behind %s owns its block', (_wrapper, wrap) => { + for (const content of [ + 'xrm -rf buildy', + 'README.md', + ]) { + const block = `${wrap(JSON.stringify({ name: 'Write', arguments: { file_path: 'a.md', content } }))}` + const { calls, toolCallRanges } = parseXmlToolCalls(`${block} done`) + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe('Write') + expect(calls[0].arguments).toEqual({ file_path: 'a.md', content }) + expect(toolCallRanges).toEqual([[0, block.length]]) + } + }) + test('preserves source order when mixing standard XML and HY3 dialects', () => { const text = 'before {"name":"xml_first","arguments":{}} ' + diff --git a/src/services/api/openaiShim/xmlToolCallParsing.ts b/src/services/api/openaiShim/xmlToolCallParsing.ts index 1bb3d9534..f6a0fd49b 100644 --- a/src/services/api/openaiShim/xmlToolCallParsing.ts +++ b/src/services/api/openaiShim/xmlToolCallParsing.ts @@ -54,9 +54,8 @@ const XML_TOOL_CALL_OPENERS = [ HY3_TOOL_CALLS_OPEN, HY3_TOOL_CALL_OPEN, ] -// Non-greedy block matcher; the `$` alternative tolerates a truncated final -// block (stream cut off before the closing tag). -const XML_TOOL_CALL_BLOCK_RE = /([\s\S]*?)(?:<\/tool_call>|$)/g +const XML_TOOL_CALL_CLOSE = '' +const XML_ARG_KEY_OPEN = '' const HY3_TOOL_CALLS_BLOCK_RE = /\s]+>([\s\S]*?)(?:<\/tool_calls(?::[^>\s]+)?>|$)/g const HY3_TOOL_CALL_BLOCK_RE = /\s]+>([\s\S]*?)(?:<\/tool_call(?::[^>\s]+)?>|$)/g const XML_FUNCTION_NAME_RE = /\s]+)\s*>/ @@ -94,19 +93,122 @@ export function findXmlToolCallOpener(text: string, allowHy3: boolean): number { }, -1) } +function isJsonText(raw: string): boolean { + try { + JSON.parse(raw) + return true + } catch { + return false + } +} + +/** + * Locates the JSON envelope (dialect C) of the body starting at `bodyStart`: + * the first `{` before the closing tag, with no dialect A/B markup before it, + * starting a valid JSON object. A label or Markdown fence may surround the + * object (`json\n{…}`, ```` ```json\n{…}\n``` ````). The body ends at the first + * closing tag after the object, or at the end of the text. + */ +function findJsonEnvelopeBody( + text: string, + bodyStart: number, +): { json: [number, number]; bodyEnd: number; end: number } | null { + const firstClose = text.indexOf(XML_TOOL_CALL_CLOSE, bodyStart) + const jsonStart = text.indexOf('{', bodyStart) + if (jsonStart === -1 || (firstClose !== -1 && jsonStart > firstClose)) return null + const lead = text.slice(bodyStart, jsonStart) + if (XML_FUNCTION_NAME_RE.test(lead) || lead.includes(XML_ARG_KEY_OPEN)) return null + const json = extractBalancedJson(text, jsonStart) + if (!json || !isJsonText(json)) return null + const jsonEnd = jsonStart + json.length + const closeIndex = text.indexOf(XML_TOOL_CALL_CLOSE, jsonEnd) + return closeIndex === -1 + ? { json: [jsonStart, jsonEnd], bodyEnd: text.length, end: text.length } + : { json: [jsonStart, jsonEnd], bodyEnd: closeIndex, end: closeIndex + XML_TOOL_CALL_CLOSE.length } +} + +/** + * The part of a single tool-call block that can hold markup. For a standard + * `` block with a JSON envelope this is the block without its JSON + * object, since tag-like text inside the object lies in JSON strings; for any + * other block it is the whole block. + */ +export function xmlToolCallBlockMarkup(block: string): string { + if (!block.startsWith(XML_TOOL_CALL_OPEN)) return block + const envelope = findJsonEnvelopeBody(block, XML_TOOL_CALL_OPEN.length) + if (envelope?.end !== block.length) return block + return block.slice(0, envelope.json[0]) + block.slice(envelope.json[1]) +} + +/** + * Finds standard `` blocks. A JSON-envelope body (dialect C) is + * bounded by its balanced JSON object, so a `` inside a JSON string + * is data and cannot end the block early. Other bodies end at the first closing + * tag; a missing closing tag runs to the end of the text (stream cut off). + */ +function findStandardXmlToolCallBlocks( + text: string, +): Array<{ range: [number, number]; inner: string; json?: string }> { + const blocks: Array<{ range: [number, number]; inner: string; json?: string }> = [] + let start = text.indexOf(XML_TOOL_CALL_OPEN) + while (start !== -1) { + const bodyStart = start + XML_TOOL_CALL_OPEN.length + const envelope = findJsonEnvelopeBody(text, bodyStart) + let end: number + if (envelope) { + end = envelope.end + blocks.push({ + range: [start, end], + inner: text.slice(bodyStart, envelope.bodyEnd), + json: text.slice(envelope.json[0], envelope.json[1]), + }) + } else { + const closeIndex = text.indexOf(XML_TOOL_CALL_CLOSE, bodyStart) + const bodyEnd = closeIndex === -1 ? text.length : closeIndex + end = closeIndex === -1 ? text.length : closeIndex + XML_TOOL_CALL_CLOSE.length + blocks.push({ range: [start, end], inner: text.slice(bodyStart, bodyEnd) }) + } + start = text.indexOf(XML_TOOL_CALL_OPEN, end) + } + return blocks +} + +/** + * Ranges of every tool-call block in `text`, whether or not it parses as a + * call, in source order. HY3 wrappers come before the blocks they contain. + */ +export function findXmlToolCallBlockRanges( + text: string, + allowHy3 = false, +): Array<[number, number]> { + const ranges = findStandardXmlToolCallBlocks(text).map(block => block.range) + if (allowHy3) { + for (const blockRe of [HY3_TOOL_CALLS_BLOCK_RE, HY3_TOOL_CALL_BLOCK_RE]) { + for (const block of text.matchAll(blockRe)) { + ranges.push([block.index!, block.index! + block[0].length]) + } + } + } + return ranges.sort((left, right) => left[0] - right[0] || right[1] - left[1]) +} + /** * Parse XML / HY3 tool-call markup from assistant text. * Callers must inject the session sequencer so XML and raw-text fallbacks * share one id space (see openaiShim façade `nextTextToolCallSequence`). */ -function parseStandardXmlToolCallInner(inner: string): { +function parseStandardXmlToolCallInner(inner: string, envelopeJson?: string): { name?: string args: Record } { let name: string | undefined const args: Record = {} - const fnMatch = inner.match(XML_FUNCTION_NAME_RE) + const trimmedInner = inner.trim() + // A JSON envelope (dialect C) owns the block: XML-looking text inside its + // argument strings is data, not dialect A/B markup. + const isJsonEnvelope = envelopeJson !== undefined + const fnMatch = isJsonEnvelope ? null : inner.match(XML_FUNCTION_NAME_RE) if (fnMatch) { // Dialect A: VALUE… name = fnMatch[1] @@ -115,9 +217,8 @@ function parseStandardXmlToolCallInner(inner: string): { if (key) args[key] = coerceXmlToolValue(p[2] ?? '') } } else { - const trimmedInner = inner.trim() const argPairs = [...inner.matchAll(XML_ARG_PAIR_RE)] - if (argPairs.length > 0 && !trimmedInner.startsWith('{')) { + if (argPairs.length > 0 && !isJsonEnvelope && !trimmedInner.startsWith('{')) { // Dialect B: leading token is the function name, then arg_key/arg_value. const nameTok = trimmedInner.split(/[\n<]/, 1)[0]?.trim() if (nameTok) name = nameTok @@ -125,26 +226,18 @@ function parseStandardXmlToolCallInner(inner: string): { const key = (p[1] ?? '').trim() if (key) args[key] = coerceXmlToolValue(p[2] ?? '') } - } else { - // Dialect C: a JSON tool-call object inside the tags. - const jsonStart = trimmedInner.indexOf('{') - if (jsonStart !== -1) { - const jsonRaw = extractBalancedJson(trimmedInner, jsonStart) - if (jsonRaw) { + } else if (isJsonEnvelope) { + // Dialect C: the JSON tool-call object located by findJsonEnvelopeBody. + const obj = JSON.parse(envelopeJson) as Record + if (typeof obj['name'] === 'string') { + name = obj['name'] as string + const rawArgs = obj['arguments'] + if (typeof rawArgs === 'string') { try { - const obj = JSON.parse(jsonRaw) as Record - if (typeof obj['name'] === 'string') { - name = obj['name'] as string - const rawArgs = obj['arguments'] - if (typeof rawArgs === 'string') { - try { - Object.assign(args, JSON.parse(rawArgs)) - } catch {} - } else if (rawArgs && typeof rawArgs === 'object') { - Object.assign(args, rawArgs as Record) - } - } + Object.assign(args, JSON.parse(rawArgs)) } catch {} + } else if (rawArgs && typeof rawArgs === 'object') { + Object.assign(args, rawArgs as Record) } } } @@ -218,12 +311,8 @@ export function parseXmlToolCalls( }) } - for (const block of text.matchAll(XML_TOOL_CALL_BLOCK_RE)) { - const range: [number, number] = [ - block.index!, - block.index! + block[0].length, - ] - const { name, args } = parseStandardXmlToolCallInner(block[1] ?? '') + for (const { range, inner, json } of findStandardXmlToolCallBlocks(text)) { + const { name, args } = parseStandardXmlToolCallInner(inner, json) if (!name) continue candidates.push({ range,