From 51b89309e69d74cbba758a874c164c3f747dbf17 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 7 Jan 2026 12:41:40 +0100 Subject: [PATCH] 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} - {"name": "...", "arguments": {...}} - {"tool_calls": [...]} JSON blobs --- .../src/lib/components/chat/ChatWindow.svelte | 74 +++++++++++++-- frontend/src/lib/tools/executor.ts | 91 +++++++++++++++++++ frontend/src/lib/tools/index.ts | 4 +- 3 files changed, 159 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte index 5367c43..ed5bfea 100644 --- a/frontend/src/lib/components/chat/ChatWindow.svelte +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -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 ); diff --git a/frontend/src/lib/tools/executor.ts b/frontend/src/lib/tools/executor.ts index 4b21e65..9c6ad11 100644 --- a/frontend/src/lib/tools/executor.ts +++ b/frontend/src/lib/tools/executor.ts @@ -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 }>; + /** 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} + * - {"name": "...", "arguments": {...}} + * + * This function detects and parses these formats. + */ +export function parseTextToolCalls(content: string): TextToolCallParseResult { + const toolCalls: Array<{ name: string; arguments: Record }> = []; + 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: {"name": "tool_name", "arguments": {...}} + const xmlPattern = /\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 }; +} diff --git a/frontend/src/lib/tools/index.ts b/frontend/src/lib/tools/index.ts index 13896ec..6062187 100644 --- a/frontend/src/lib/tools/index.ts +++ b/frontend/src/lib/tools/index.ts @@ -8,11 +8,13 @@ export { toolRegistry, executeCustomTool, parseToolCall, + parseTextToolCalls, runToolCall, runToolCalls, formatToolResultsForChat, createToolCallState, - updateToolCallState + updateToolCallState, + type TextToolCallParseResult } from './executor.js'; export { PREFERRED_FUNCTION_MODEL,