fix: normalize ollama tool calls from content and thinking
This commit is contained in:
@@ -19,6 +19,14 @@ export function parseXmlToolCalls(content: string): ParsedToolCall[] {
|
||||
|
||||
const results: ParsedToolCall[] = [];
|
||||
|
||||
const pushParsedCall = (name: string, args: Record<string, any>) => {
|
||||
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 <function=NAME>...</function> block
|
||||
// We use `[\s\S]*?` for non-greedy multiline matching
|
||||
const functionRegex = /<function=([^>]+)>([\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 <tool_call>...</tool_call>
|
||||
const jsonToolCallRegex = /<tool_call>\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<string, any> = {};
|
||||
|
||||
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
|
||||
|
||||
@@ -3,6 +3,17 @@ import { parseXmlToolCalls } from '../parsers';
|
||||
import { logger } from '../utils/logger';
|
||||
import { sanitizeContent } from '../utils/content-sanitizer';
|
||||
|
||||
function buildStandardToolCalls(parsedCalls: ReturnType<typeof parseXmlToolCalls>): 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(/<function=([^>]+)>([\s\S]*?)<\/function>/g, '');
|
||||
cleanedContent = cleanedContent.replace(/<tool_call>([\s\S]*?)<\/tool_call>/g, '');
|
||||
response.message.content = sanitizeContent(cleanedContent);
|
||||
if (response.message.thinking) {
|
||||
response.message.thinking = sanitizeContent(
|
||||
response.message.thinking
|
||||
.replace(/<function=([^>]+)>([\s\S]*?)<\/function>/g, '')
|
||||
.replace(/<tool_call>([\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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface OllamaMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,28 +2,32 @@
|
||||
* Strips internal model artifacts from content before sending to client.
|
||||
*
|
||||
* Removes:
|
||||
* - <think>...</think> blocks (internal reasoning, never visible)
|
||||
* - Orphan closing tags like </function>, </think>, </tool>, </total>
|
||||
* (these have no semantic value to the client)
|
||||
* - <thinking>...</thinking> blocks (Claude-style internal reasoning)
|
||||
* - <think>...</think> blocks (Qwen-style internal reasoning)
|
||||
* - Orphan closing tags like </function>, </think>, </thinking>, </tool>, </total>
|
||||
*/
|
||||
export function sanitizeContent(content: string): string {
|
||||
if (!content) return content;
|
||||
|
||||
let cleaned = content;
|
||||
|
||||
// 1. Strip complete <think>...</think> blocks
|
||||
// 1. Strip complete <thinking>...</thinking> blocks (Claude-distilled models)
|
||||
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/g, '');
|
||||
|
||||
// 2. Strip complete <think>...</think> blocks (Qwen-style)
|
||||
cleaned = cleaned.replace(/<think>[\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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "<tool_call>\n{\"name\":\"read\",\"arguments\":{\"path\":\"/tmp/test.txt\"}}\n</tool_call>"
|
||||
}
|
||||
};
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = `
|
||||
<tool_call>
|
||||
{"name":"read","arguments":{"path":"/tmp/test.txt"}}
|
||||
</tool_call>
|
||||
`;
|
||||
|
||||
const result = parseXmlToolCalls(content);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('read');
|
||||
expect(result[0].args).toEqual({ path: '/tmp/test.txt' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user