From 8eb7b25ec9f34f1a8334fa14179ba46a19d107dc Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Sun, 22 Mar 2026 19:54:47 +0800 Subject: [PATCH] fix: normalize ollama tool calls from content and thinking --- src/parsers/xml-toolcall.ts | 46 ++++++++++++++++++++++--- src/proxy/response-rewriter.ts | 63 +++++++++++++++++----------------- src/types/ollama.ts | 1 + src/utils/content-sanitizer.ts | 20 ++++++----- test/response-rewriter.test.ts | 21 ++++++++++++ test/xml-toolcall.test.ts | 13 +++++++ 6 files changed, 121 insertions(+), 43 deletions(-) diff --git a/src/parsers/xml-toolcall.ts b/src/parsers/xml-toolcall.ts index 490ce87..541d53e 100755 --- a/src/parsers/xml-toolcall.ts +++ b/src/parsers/xml-toolcall.ts @@ -19,6 +19,14 @@ export function parseXmlToolCalls(content: string): ParsedToolCall[] { const results: ParsedToolCall[] = []; + const pushParsedCall = (name: string, args: Record) => { + if (!name) return; + if (name === 'FUNCTION_NAME') return; + if (Object.keys(args).some((key) => key === 'ARG_NAME')) return; + if (Object.values(args).some((value) => value === 'ARG_VALUE')) return; + results.push({ name, args }); + }; + // Match each ... block // We use `[\s\S]*?` for non-greedy multiline matching const functionRegex = /]+)>([\s\S]*?)<\/function>/g; @@ -40,10 +48,40 @@ export function parseXmlToolCalls(content: string): ParsedToolCall[] { } // Sometimes arguments are JSON encoded strings inside XML, or we can just pass them as strings. - results.push({ - name, - args - }); + pushParsedCall(name, args); + } + + // Match JSON tool calls wrapped in ... + const jsonToolCallRegex = /\s*([\s\S]*?)\s*<\/tool_call>/g; + + while ((match = jsonToolCallRegex.exec(content)) !== null) { + const rawPayload = match[1]?.trim(); + if (!rawPayload || !rawPayload.startsWith('{')) { + continue; + } + + try { + const parsed = JSON.parse(rawPayload); + const name = typeof parsed?.name === 'string' ? parsed.name.trim() : ''; + let args: Record = {}; + + if (parsed?.arguments && typeof parsed.arguments === 'object' && !Array.isArray(parsed.arguments)) { + args = parsed.arguments; + } else if (typeof parsed?.arguments === 'string') { + try { + const nested = JSON.parse(parsed.arguments); + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + args = nested; + } + } catch { + args = { value: parsed.arguments }; + } + } + + pushParsedCall(name, args); + } catch { + logger.debug('Skipping invalid JSON tool call payload.'); + } } // Debug logging if we found anything diff --git a/src/proxy/response-rewriter.ts b/src/proxy/response-rewriter.ts index 7837828..fce8c2d 100755 --- a/src/proxy/response-rewriter.ts +++ b/src/proxy/response-rewriter.ts @@ -3,6 +3,17 @@ import { parseXmlToolCalls } from '../parsers'; import { logger } from '../utils/logger'; import { sanitizeContent } from '../utils/content-sanitizer'; +function buildStandardToolCalls(parsedCalls: ReturnType): ToolCall[] { + return parsedCalls.map((call, index) => ({ + id: `call_${Date.now()}_${index}`, + type: 'function', + function: { + name: call.name, + arguments: call.args || {}, + } + })); +} + /** * Rewrites the Ollama response to include structured tool calls if missing * but present in XML tags within the content. @@ -30,46 +41,36 @@ export function rewriteResponse(response: OllamaChatResponse): OllamaChatRespons return response; } - const content = response.message.content; - if (!content) { - return response; - } - - // Try to parse XML tool calls from content - const parsedCalls = parseXmlToolCalls(content); + const content = response.message.content || ''; + const thinking = response.message.thinking || ''; + const parsedCalls = [ + ...parseXmlToolCalls(content), + ...parseXmlToolCalls(thinking), + ]; if (parsedCalls.length > 0) { - logger.info(`Rewriting response: found ${parsedCalls.length} tool calls in XML content`); - - // Construct standard tool_calls - const standardToolCalls: ToolCall[] = parsedCalls.map((call, index) => { - // Ollama expects arguments as a JSON object, not a string! - const argsObject = call.args || {}; + logger.info(`Rewriting response: found ${parsedCalls.length} tool calls in content/thinking`); - return { - id: `call_${Date.now()}_${index}`, - type: 'function', - function: { - name: call.name, - arguments: argsObject, // Object, not stringified - } - }; - }); + response.message.tool_calls = buildStandardToolCalls(parsedCalls); + response.message.content = ''; - // We can decide to either clear the content or keep it. - // Usually, if we parsed tool calls, we clear the content to avoid confusion - // But retaining it is also fine. Let's clear the XML parts or the whole content to be safe. - response.message.tool_calls = standardToolCalls; - // Erase tool XML then sanitize orphan tags and think blocks - let cleanedContent = content.replace(/]+)>([\s\S]*?)<\/function>/g, ''); - cleanedContent = cleanedContent.replace(/([\s\S]*?)<\/tool_call>/g, ''); - response.message.content = sanitizeContent(cleanedContent); + if (response.message.thinking) { + response.message.thinking = sanitizeContent( + response.message.thinking + .replace(/]+)>([\s\S]*?)<\/function>/g, '') + .replace(/([\s\S]*?)<\/tool_call>/g, '') + ); + } + return response; } // Sanitize plain text responses too - if ((!response.message.tool_calls || response.message.tool_calls.length === 0) && response.message.content) { + if (response.message.content) { response.message.content = sanitizeContent(response.message.content); } + if (response.message.thinking) { + response.message.thinking = sanitizeContent(response.message.thinking); + } return response; } diff --git a/src/types/ollama.ts b/src/types/ollama.ts index 8c282be..6174ab0 100755 --- a/src/types/ollama.ts +++ b/src/types/ollama.ts @@ -1,6 +1,7 @@ export interface OllamaMessage { role: string; content: string; + thinking?: string; tool_calls?: ToolCall[]; } diff --git a/src/utils/content-sanitizer.ts b/src/utils/content-sanitizer.ts index 976016d..540ca04 100755 --- a/src/utils/content-sanitizer.ts +++ b/src/utils/content-sanitizer.ts @@ -2,28 +2,32 @@ * Strips internal model artifacts from content before sending to client. * * Removes: - * - ... blocks (internal reasoning, never visible) - * - Orphan closing tags like , , , - * (these have no semantic value to the client) + * - ... blocks (Claude-style internal reasoning) + * - ... blocks (Qwen-style internal reasoning) + * - Orphan closing tags like , , , , */ export function sanitizeContent(content: string): string { if (!content) return content; let cleaned = content; - // 1. Strip complete ... blocks + // 1. Strip complete ... blocks (Claude-distilled models) + cleaned = cleaned.replace(/[\s\S]*?<\/thinking>/g, ''); + + // 2. Strip complete ... blocks (Qwen-style) cleaned = cleaned.replace(/[\s\S]*?<\/think>/g, ''); - // 2. Strip orphan closing tags that have no matching opener in this output - // Only orphan tags (not ones inside legitimate code/markdown fences) - cleaned = cleaned.replace(/<\/function>/g, ''); + // 3. Strip orphan closing tags (no matching opener in this output) + cleaned = cleaned.replace(/<\/thinking>/g, ''); cleaned = cleaned.replace(/<\/think>/g, ''); + cleaned = cleaned.replace(/<\/function>/g, ''); cleaned = cleaned.replace(/<\/tool>/g, ''); cleaned = cleaned.replace(/<\/tool_call>/g, ''); cleaned = cleaned.replace(/<\/total>/g, ''); - // 3. Collapse excessive blank lines left after stripping + // 4. Collapse excessive blank lines left after stripping cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); return cleaned.trim(); } + diff --git a/test/response-rewriter.test.ts b/test/response-rewriter.test.ts index 1b891b8..e06d7c0 100755 --- a/test/response-rewriter.test.ts +++ b/test/response-rewriter.test.ts @@ -49,4 +49,25 @@ describe('Response Rewriter', () => { expect(result.message.content).toBe("Here are the calls"); // not cleared expect(result.message.tool_calls).toHaveLength(1); }); + + it('rewrites tool call found in thinking into structured tool_calls', () => { + const inputResponse: OllamaChatResponse = { + model: "test-model", + done: true, + message: { + role: "assistant", + content: "", + thinking: "\n{\"name\":\"read\",\"arguments\":{\"path\":\"/tmp/test.txt\"}}\n" + } + }; + + const result = rewriteResponse(inputResponse); + + expect(result.message.content).toBe(""); + expect(result.message.tool_calls).toBeDefined(); + expect(result.message.tool_calls).toHaveLength(1); + expect(result.message.tool_calls![0].function.name).toBe("read"); + expect(result.message.tool_calls![0].function.arguments).toEqual({ path: "/tmp/test.txt" }); + expect(result.message.thinking).toBe(""); + }); }); diff --git a/test/xml-toolcall.test.ts b/test/xml-toolcall.test.ts index 3dda13c..f4d80fa 100755 --- a/test/xml-toolcall.test.ts +++ b/test/xml-toolcall.test.ts @@ -45,4 +45,17 @@ hello const result = parseXmlToolCalls(content); expect(result).toHaveLength(0); }); + + it('parses JSON tool calls wrapped in tool_call tags', () => { + const content = ` + +{"name":"read","arguments":{"path":"/tmp/test.txt"}} + + `; + + const result = parseXmlToolCalls(content); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('read'); + expect(result[0].args).toEqual({ path: '/tmp/test.txt' }); + }); });