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
464 lines
11 KiB
TypeScript
464 lines
11 KiB
TypeScript
/**
|
|
* Tool Executor - Handles running tools and managing results
|
|
*/
|
|
|
|
import type {
|
|
ToolCall,
|
|
ParsedToolCall,
|
|
ToolResult,
|
|
ToolRegistryEntry,
|
|
ToolDefinition,
|
|
ToolContext,
|
|
ToolCallState,
|
|
CustomTool
|
|
} from './types.js';
|
|
import { builtinTools, getBuiltinToolDefinitions } from './builtin.js';
|
|
|
|
/**
|
|
* Execute a custom JavaScript tool
|
|
*
|
|
* SECURITY NOTE: This intentionally executes user-provided JavaScript code.
|
|
* This is by design - users create custom tools with their own code.
|
|
* The code runs in the browser context with the user's own permissions.
|
|
* This is similar to browser DevTools console - users execute their own code.
|
|
*/
|
|
async function executeJavaScriptTool(tool: CustomTool, args: Record<string, unknown>): Promise<unknown> {
|
|
if (!tool.code) {
|
|
throw new Error('JavaScript tool has no code');
|
|
}
|
|
|
|
try {
|
|
// Create an async function to support await in tool code
|
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
const fn = new AsyncFunction('args', `
|
|
"use strict";
|
|
${tool.code}
|
|
`);
|
|
return await fn(args);
|
|
} catch (error) {
|
|
throw new Error(`Tool execution error: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a custom Python tool via backend API
|
|
*
|
|
* SECURITY NOTE: This sends user-provided Python code to the backend for execution.
|
|
* This is by design - users create custom tools with their own code.
|
|
* The backend executes code in a subprocess with timeout protection.
|
|
*/
|
|
async function executePythonTool(tool: CustomTool, args: Record<string, unknown>): Promise<unknown> {
|
|
if (!tool.code) {
|
|
throw new Error('Python tool has no code');
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/tools/execute', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
language: 'python',
|
|
code: tool.code,
|
|
args,
|
|
timeout: 30
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Backend error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data.success) {
|
|
throw new Error(data.error || 'Python execution failed');
|
|
}
|
|
|
|
return data.result;
|
|
} catch (error) {
|
|
throw new Error(`Python tool error: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a custom HTTP tool
|
|
*/
|
|
async function executeHttpTool(tool: CustomTool, args: Record<string, unknown>): Promise<unknown> {
|
|
if (!tool.endpoint) {
|
|
throw new Error('HTTP tool has no endpoint');
|
|
}
|
|
|
|
const method = tool.httpMethod || 'POST';
|
|
const url = new URL(tool.endpoint);
|
|
|
|
const options: RequestInit = {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
};
|
|
|
|
if (method === 'GET') {
|
|
// Add args as query parameters
|
|
for (const [key, value] of Object.entries(args)) {
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
} else {
|
|
options.body = JSON.stringify(args);
|
|
}
|
|
|
|
const response = await fetch(url.toString(), options);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType?.includes('application/json')) {
|
|
return response.json();
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
/**
|
|
* Tool Registry - Manages all available tools (builtin + custom)
|
|
*/
|
|
class ToolRegistry {
|
|
private tools: Map<string, ToolRegistryEntry> = new Map();
|
|
|
|
constructor() {
|
|
// Initialize with builtin tools
|
|
for (const [name, entry] of builtinTools) {
|
|
this.tools.set(name, entry);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a custom tool
|
|
*/
|
|
register(name: string, entry: ToolRegistryEntry): void {
|
|
this.tools.set(name, entry);
|
|
}
|
|
|
|
/**
|
|
* Unregister a tool
|
|
*/
|
|
unregister(name: string): boolean {
|
|
const entry = this.tools.get(name);
|
|
if (entry?.isBuiltin) {
|
|
return false; // Cannot unregister builtin tools
|
|
}
|
|
return this.tools.delete(name);
|
|
}
|
|
|
|
/**
|
|
* Get a tool by name
|
|
*/
|
|
get(name: string): ToolRegistryEntry | undefined {
|
|
return this.tools.get(name);
|
|
}
|
|
|
|
/**
|
|
* Check if a tool exists
|
|
*/
|
|
has(name: string): boolean {
|
|
return this.tools.has(name);
|
|
}
|
|
|
|
/**
|
|
* Get all tool definitions (for Ollama API)
|
|
*/
|
|
getDefinitions(): ToolDefinition[] {
|
|
return Array.from(this.tools.values()).map(entry => entry.definition);
|
|
}
|
|
|
|
/**
|
|
* Get builtin tool definitions only
|
|
*/
|
|
getBuiltinDefinitions(): ToolDefinition[] {
|
|
return getBuiltinToolDefinitions();
|
|
}
|
|
|
|
/**
|
|
* Get all tool names
|
|
*/
|
|
getNames(): string[] {
|
|
return Array.from(this.tools.keys());
|
|
}
|
|
|
|
/**
|
|
* Get count of registered tools
|
|
*/
|
|
get size(): number {
|
|
return this.tools.size;
|
|
}
|
|
}
|
|
|
|
/** Singleton registry instance */
|
|
export const toolRegistry = new ToolRegistry();
|
|
|
|
/**
|
|
* Execute a custom tool by its definition
|
|
*/
|
|
export async function executeCustomTool(
|
|
tool: CustomTool,
|
|
args: Record<string, unknown>
|
|
): Promise<unknown> {
|
|
switch (tool.implementation) {
|
|
case 'javascript':
|
|
return executeJavaScriptTool(tool, args);
|
|
case 'python':
|
|
return executePythonTool(tool, args);
|
|
case 'http':
|
|
return executeHttpTool(tool, args);
|
|
default:
|
|
throw new Error(`Unknown implementation type: ${tool.implementation}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a tool call from model response
|
|
*/
|
|
export function parseToolCall(call: ToolCall): ParsedToolCall {
|
|
let args: Record<string, unknown> = {};
|
|
|
|
try {
|
|
args = JSON.parse(call.function.arguments);
|
|
} catch {
|
|
// If JSON parsing fails, try to extract as simple value
|
|
args = { value: call.function.arguments };
|
|
}
|
|
|
|
return {
|
|
id: call.id,
|
|
name: call.function.name,
|
|
arguments: args
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run a single tool call
|
|
* @param call - The tool call from the model
|
|
* @param context - Optional execution context
|
|
* @param customTools - Optional array of custom tools to check
|
|
*/
|
|
export async function runToolCall(
|
|
call: ToolCall | ParsedToolCall,
|
|
context?: ToolContext,
|
|
customTools?: CustomTool[]
|
|
): Promise<ToolResult> {
|
|
const parsed = 'function' in call ? parseToolCall(call) : call;
|
|
const { id, name, arguments: args } = parsed;
|
|
|
|
// First check builtin tools in registry
|
|
const entry = toolRegistry.get(name);
|
|
if (entry) {
|
|
try {
|
|
const result = await entry.handler(args);
|
|
|
|
// Check if result is an error object
|
|
if (result && typeof result === 'object' && 'error' in result) {
|
|
const errorObj = result as { error: unknown; suggestion?: string };
|
|
// Include suggestion in error message if present
|
|
const errorMsg = errorObj.suggestion
|
|
? `${String(errorObj.error)}. ${errorObj.suggestion}`
|
|
: String(errorObj.error);
|
|
return {
|
|
toolCallId: id,
|
|
success: false,
|
|
error: errorMsg
|
|
};
|
|
}
|
|
|
|
return {
|
|
toolCallId: id,
|
|
success: true,
|
|
result
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
toolCallId: id,
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error during tool execution'
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check custom tools
|
|
if (customTools) {
|
|
const customTool = customTools.find(t => t.name === name && t.enabled);
|
|
if (customTool) {
|
|
try {
|
|
const result = await executeCustomTool(customTool, args);
|
|
return {
|
|
toolCallId: id,
|
|
success: true,
|
|
result
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
toolCallId: id,
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Custom tool execution failed'
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
toolCallId: id,
|
|
success: false,
|
|
error: `Unknown tool: ${name}`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run multiple tool calls in parallel
|
|
*/
|
|
export async function runToolCalls(
|
|
calls: (ToolCall | ParsedToolCall)[],
|
|
context?: ToolContext,
|
|
customTools?: CustomTool[]
|
|
): Promise<ToolResult[]> {
|
|
return Promise.all(calls.map(call => runToolCall(call, context, customTools)));
|
|
}
|
|
|
|
/**
|
|
* Format tool results for inclusion in chat message
|
|
*/
|
|
export function formatToolResultsForChat(results: ToolResult[]): string {
|
|
return results
|
|
.map(result => {
|
|
if (result.success) {
|
|
const value = typeof result.result === 'object'
|
|
? JSON.stringify(result.result, null, 2)
|
|
: String(result.result);
|
|
return `Tool result: ${value}`;
|
|
} else {
|
|
return `Tool error: ${result.error}`;
|
|
}
|
|
})
|
|
.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Create a tool call state for UI tracking
|
|
*/
|
|
export function createToolCallState(call: ToolCall | ParsedToolCall): ToolCallState {
|
|
const parsed = 'function' in call ? parseToolCall(call) : call;
|
|
return {
|
|
id: parsed.id,
|
|
name: parsed.name,
|
|
arguments: parsed.arguments,
|
|
status: 'pending',
|
|
startTime: Date.now()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update tool call state with result
|
|
*/
|
|
export function updateToolCallState(
|
|
state: ToolCallState,
|
|
result: ToolResult
|
|
): ToolCallState {
|
|
return {
|
|
...state,
|
|
status: result.success ? 'success' : 'error',
|
|
result: result.result,
|
|
error: result.error,
|
|
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 };
|
|
}
|