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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ export {
|
||||
toolRegistry,
|
||||
executeCustomTool,
|
||||
parseToolCall,
|
||||
parseTextToolCalls,
|
||||
runToolCall,
|
||||
runToolCalls,
|
||||
formatToolResultsForChat,
|
||||
createToolCallState,
|
||||
updateToolCallState
|
||||
updateToolCallState,
|
||||
type TextToolCallParseResult
|
||||
} from './executor.js';
|
||||
export {
|
||||
PREFERRED_FUNCTION_MODEL,
|
||||
|
||||
Reference in New Issue
Block a user