fix: parse text-based tool calls for models without native function calling

Models like ministral output tool calls as plain text (e.g., tool_name[ARGS]{json})
instead of using Ollama's native tool_calls format. This adds a parser that:

- Detects text-based tool call patterns in model output
- Converts them to OllamaToolCall format for execution
- Cleans the raw tool call text from the message
- Shows proper ToolCallDisplay UI with styled output

Supports three formats:
- tool_name[ARGS]{json}
- <tool_call>{"name": "...", "arguments": {...}}</tool_call>
- {"tool_calls": [...]} JSON blobs
This commit is contained in:
2026-01-07 12:41:40 +01:00
parent 566273415f
commit 51b89309e6
3 changed files with 159 additions and 10 deletions

View File

@@ -23,7 +23,7 @@
formatResultsAsContext,
getKnowledgeBaseStats
} from '$lib/memory';
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL } from '$lib/tools';
import { runToolCalls, formatToolResultsForChat, getFunctionModel, USE_FUNCTION_MODEL, parseTextToolCalls } from '$lib/tools';
import type { OllamaMessage, OllamaToolCall, OllamaToolDefinition } from '$lib/ollama';
import type { Conversation } from '$lib/types/conversation';
import VirtualMessageList from './VirtualMessageList.svelte';
@@ -742,7 +742,7 @@
streamingMetricsState.endStream();
abortController = null;
// Handle tool calls if received
// Handle native tool calls if received
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
model,
@@ -753,13 +753,41 @@
return; // Tool continuation handles persistence
}
// Check for text-based tool calls (models without native tool calling)
const node = chatState.messageTree.get(assistantMessageId);
if (node && toolsState.toolsEnabled) {
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
if (textToolCalls.length > 0) {
// Convert to OllamaToolCall format
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
function: {
name: tc.name,
arguments: tc.arguments
}
}));
// Update message content to remove the raw tool call text
if (cleanContent !== node.message.content) {
node.message.content = cleanContent || 'Using tool...';
}
await executeToolsAndContinue(
model,
assistantMessageId,
convertedCalls,
conversationId
);
return; // Tool continuation handles persistence
}
}
// Persist assistant message to IndexedDB with the SAME ID as chatState
if (conversationId) {
const node = chatState.messageTree.get(assistantMessageId);
if (node) {
const nodeForPersist = chatState.messageTree.get(assistantMessageId);
if (nodeForPersist) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: node.message.content },
{ role: 'assistant', content: nodeForPersist.message.content },
parentMessageId,
assistantMessageId
);
@@ -964,7 +992,7 @@
streamingMetricsState.endStream();
abortController = null;
// Handle tool calls if received
// Handle native tool calls if received
if (pendingToolCalls && pendingToolCalls.length > 0) {
await executeToolsAndContinue(
selectedModel,
@@ -975,13 +1003,41 @@
return;
}
// Check for text-based tool calls (models without native tool calling)
const node = chatState.messageTree.get(newMessageId);
if (node && toolsState.toolsEnabled) {
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
if (textToolCalls.length > 0) {
// Convert to OllamaToolCall format
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
function: {
name: tc.name,
arguments: tc.arguments
}
}));
// Update message content to remove the raw tool call text
if (cleanContent !== node.message.content) {
node.message.content = cleanContent || 'Using tool...';
}
await executeToolsAndContinue(
selectedModel,
newMessageId,
convertedCalls,
conversationId
);
return;
}
}
// Persist regenerated assistant message to IndexedDB with the SAME ID
if (conversationId && parentUserMessageId) {
const node = chatState.messageTree.get(newMessageId);
if (node) {
const nodeForPersist = chatState.messageTree.get(newMessageId);
if (nodeForPersist) {
await addStoredMessage(
conversationId,
{ role: 'assistant', content: node.message.content },
{ role: 'assistant', content: nodeForPersist.message.content },
parentUserMessageId,
newMessageId
);

View File

@@ -370,3 +370,94 @@ export function updateToolCallState(
endTime: Date.now()
};
}
/**
* Result of parsing text-based tool calls from content
*/
export interface TextToolCallParseResult {
/** Any tool calls found in the content */
toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>;
/** Content with tool calls removed (for display) */
cleanContent: string;
}
/**
* Parse text-based tool calls from model output
*
* Models without native function calling may output tool calls as plain text
* in formats like:
* - tool_name[ARGS]{json}
* - <tool_call>{"name": "...", "arguments": {...}}</tool_call>
*
* This function detects and parses these formats.
*/
export function parseTextToolCalls(content: string): TextToolCallParseResult {
const toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
let cleanContent = content;
// Pattern 1: tool_name[ARGS]{json} or tool_name[ARGS]{"key": "value"}
const argsPattern = /(\w+)\[ARGS\]\s*(\{[\s\S]*?\})/g;
const argsMatches = [...content.matchAll(argsPattern)];
for (const match of argsMatches) {
const [fullMatch, toolName, argsJson] = match;
try {
const args = JSON.parse(argsJson);
toolCalls.push({ name: toolName, arguments: args });
cleanContent = cleanContent.replace(fullMatch, '').trim();
} catch {
// JSON parse failed, skip this match
console.warn(`Failed to parse tool call arguments: ${argsJson}`);
}
}
// Pattern 2: <tool_call>{"name": "tool_name", "arguments": {...}}</tool_call>
const xmlPattern = /<tool_call>\s*(\{[\s\S]*?\})\s*<\/tool_call>/g;
const xmlMatches = [...content.matchAll(xmlPattern)];
for (const match of xmlMatches) {
const [fullMatch, json] = match;
try {
const parsed = JSON.parse(json);
if (parsed.name && parsed.arguments) {
toolCalls.push({
name: parsed.name,
arguments: typeof parsed.arguments === 'string'
? JSON.parse(parsed.arguments)
: parsed.arguments
});
cleanContent = cleanContent.replace(fullMatch, '').trim();
}
} catch {
console.warn(`Failed to parse XML tool call: ${json}`);
}
}
// Pattern 3: {"tool_calls": [{"function": {"name": "...", "arguments": {...}}}]}
const jsonBlobPattern = /\{[\s\S]*?"tool_calls"\s*:\s*\[[\s\S]*?\]\s*\}/g;
const jsonMatches = [...content.matchAll(jsonBlobPattern)];
for (const match of jsonMatches) {
const [fullMatch] = match;
try {
const parsed = JSON.parse(fullMatch);
if (Array.isArray(parsed.tool_calls)) {
for (const tc of parsed.tool_calls) {
if (tc.function?.name) {
toolCalls.push({
name: tc.function.name,
arguments: typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments || {}
});
}
}
cleanContent = cleanContent.replace(fullMatch, '').trim();
}
} catch {
// Not valid JSON, skip
}
}
return { toolCalls, cleanContent };
}

View File

@@ -8,11 +8,13 @@ export {
toolRegistry,
executeCustomTool,
parseToolCall,
parseTextToolCalls,
runToolCall,
runToolCalls,
formatToolResultsForChat,
createToolCallState,
updateToolCallState
updateToolCallState,
type TextToolCallParseResult
} from './executor.js';
export {
PREFERRED_FUNCTION_MODEL,