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,