diff --git a/README.md b/README.md index 881922f2..f2223722 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,7 @@ Nanocoder looks for configuration in the following order (first found wins): - `baseUrl`: OpenAI-compatible API endpoint - `apiKey`: API key (optional, may not be required) - `models`: Available model list for `/model` command +- `disableToolModels`: List of model names to disable tool calling for (optional) **Environment Variables:** @@ -403,7 +404,7 @@ Keep API keys out of version control using environment variables. Variables are - `NANOCODER_DATA_DIR`: Override the application data directory used for internal data like usage statistics. **Syntax:** `$VAR_NAME`, `${VAR_NAME}`, or `${VAR_NAME:-default}` -**Supported in:** `baseUrl`, `apiKey`, `models`, MCP server `command`, `args`, `env` +**Supported in:** `baseUrl`, `apiKey`, `models`, `disableToolModels`, `MCP server`, `command`, `args`, `env` See `.env.example` for setup instructions diff --git a/agents.config.example.json b/agents.config.example.json index 099d32c7..ace2eca8 100644 --- a/agents.config.example.json +++ b/agents.config.example.json @@ -16,7 +16,8 @@ { "name": "Local Ollama", "baseUrl": "http://localhost:11434/v1", - "models": ["llama3.2", "qwen2.5-coder"] + "models": ["llama3.2", "qwen2.5-coder"], + "disableToolModels": ["deepseek-r1:latest"] }, { "name": "OpenAI Compatible", diff --git a/source/ai-sdk-client/chat/chat-handler.ts b/source/ai-sdk-client/chat/chat-handler.ts index 971352a6..7498ec34 100644 --- a/source/ai-sdk-client/chat/chat-handler.ts +++ b/source/ai-sdk-client/chat/chat-handler.ts @@ -32,6 +32,8 @@ import { } from '../converters/tool-converter.js'; import {extractRootError} from '../error-handling/error-extractor.js'; import {parseAPIError} from '../error-handling/error-parser.js'; +import {isToolSupportError} from '../error-handling/tool-error-detector.js'; +import {formatToolsForPrompt} from '../tools/tool-prompt-formatter.js'; import { createOnStepFinishHandler, createPrepareStepHandler, @@ -47,6 +49,7 @@ export interface ChatHandlerParams { callbacks: StreamCallbacks; signal?: AbortSignal; maxRetries: number; + skipTools?: boolean; // Track if we're retrying without tools } /** @@ -64,6 +67,7 @@ export async function handleChat( callbacks, signal, maxRetries, + skipTools = false, } = params; const logger = getLogger(); @@ -73,14 +77,33 @@ export async function handleChat( throw new Error('Operation was cancelled'); } + // Check if tools should be disabled + const shouldDisableTools = + skipTools || + providerConfig.disableTools || + (providerConfig.disableToolModels && + providerConfig.disableToolModels.includes(currentModel)); + // Start performance tracking const metrics = startMetrics(); const correlationId = getCorrelationId() || generateCorrelationId(); + if (shouldDisableTools) { + logger.info('Tools disabled for request', { + model: currentModel, + reason: skipTools + ? 'retry without tools' + : providerConfig.disableTools + ? 'provider configuration' + : 'model configuration', + correlationId, + }); + } + logger.info('Chat request starting', { model: currentModel, messageCount: messages.length, - toolCount: Object.keys(tools).length, + toolCount: shouldDisableTools ? 0 : Object.keys(tools).length, correlationId, provider: providerConfig.name, }); @@ -88,10 +111,38 @@ export async function handleChat( return await withNewCorrelationContext(async _context => { try { // Tools are already in AI SDK format - use directly - const aiTools = Object.keys(tools).length > 0 ? tools : undefined; + const aiTools = shouldDisableTools + ? undefined + : Object.keys(tools).length > 0 + ? tools + : undefined; + + // When native tools are disabled but we have tools, inject definitions into system prompt + // This allows the model to still use tools via XML format + let messagesWithToolPrompt = messages; + if (shouldDisableTools && Object.keys(tools).length > 0) { + const toolPrompt = formatToolsForPrompt(tools); + if (toolPrompt) { + // Find and augment the system message with tool definitions + messagesWithToolPrompt = messages.map((msg, index) => { + if (msg.role === 'system' && index === 0) { + return { + ...msg, + content: msg.content + toolPrompt, + }; + } + return msg; + }); + + logger.debug('Injected tool definitions into system prompt', { + toolCount: Object.keys(tools).length, + promptLength: toolPrompt.length, + }); + } + } // Convert messages to AI SDK v5 ModelMessage format - const modelMessages = convertToModelMessages(messages); + const modelMessages = convertToModelMessages(messagesWithToolPrompt); logger.debug('AI SDK request prepared', { messageCount: modelMessages.length, @@ -242,6 +293,22 @@ export async function handleChat( throw new Error('Operation was cancelled'); } + // Check if error indicates tool support issue and we haven't retried + if (!skipTools && isToolSupportError(error)) { + logger.warn('Tool support error detected, retrying without tools', { + model: currentModel, + error: error instanceof Error ? error.message : error, + correlationId, + provider: providerConfig.name, + }); + + // Retry without tools + return await handleChat({ + ...params, + skipTools: true, // Mark that we're retrying + }); + } + // Handle tool-specific errors - NoSuchToolError if (error instanceof NoSuchToolError) { logger.error('Tool not found', { @@ -254,8 +321,8 @@ export async function handleChat( // Provide helpful error message with available tools const availableTools = Object.keys(tools).join(', '); const errorMessage = availableTools - ? `Tool "$\{error.toolName\}" does not exist. Available tools: $\{availableTools\}` - : `Tool "$\{error.toolName\}" does not exist and no tools are currently loaded.`; + ? `Tool "${error.toolName}" does not exist. Available tools: ${availableTools}` + : `Tool "${error.toolName}" does not exist and no tools are currently loaded.`; throw new Error(errorMessage); } @@ -272,7 +339,7 @@ export async function handleChat( // Provide clear validation error throw new Error( - `Invalid arguments for tool "$\{error.toolName\}": $\{error.message\}`, + `Invalid arguments for tool "${error.toolName}": ${error.message}`, ); } diff --git a/source/ai-sdk-client/error-handling/tool-error-detector.spec.ts b/source/ai-sdk-client/error-handling/tool-error-detector.spec.ts new file mode 100644 index 00000000..54345a30 --- /dev/null +++ b/source/ai-sdk-client/error-handling/tool-error-detector.spec.ts @@ -0,0 +1,225 @@ +import test from 'ava'; +import {isToolSupportError} from './tool-error-detector.js'; + +test('isToolSupportError returns false for non-Error values', t => { + t.false(isToolSupportError(null)); + t.false(isToolSupportError(undefined)); + t.false(isToolSupportError('string error')); + t.false(isToolSupportError(123)); + t.false(isToolSupportError({})); +}); + +test('isToolSupportError returns false for generic errors without tool patterns', t => { + t.false(isToolSupportError(new Error('Something went wrong'))); + t.false(isToolSupportError(new Error('Network error'))); + t.false(isToolSupportError(new Error('Rate limit exceeded'))); +}); + +test('isToolSupportError detects 400 Bad Request with tool keyword', t => { + t.true( + isToolSupportError( + new Error('400 Bad Request: invalid parameter "tools"'), + ), + ); + t.true( + isToolSupportError( + new Error('400 bad request: unexpected field "tools"'), + ), + ); +}); + +test('isToolSupportError detects 400 Bad Request with function keyword', t => { + t.true( + isToolSupportError( + new Error('400 Bad Request: invalid parameter "functions"'), + ), + ); + t.true( + isToolSupportError( + new Error('400 bad request: unrecognized field "function_calls"'), + ), + ); +}); + +test('isToolSupportError detects 400 Bad Request with invalid parameter', t => { + t.true( + isToolSupportError( + new Error('400 Bad Request: invalid parameter found'), + ), + ); +}); + +test('isToolSupportError detects 400 Bad Request with unexpected field', t => { + t.true( + isToolSupportError( + new Error('400 bad request: unexpected field in request'), + ), + ); +}); + +test('isToolSupportError detects 400 Bad Request with unrecognized keyword', t => { + t.true( + isToolSupportError( + new Error('400 Bad Request: unrecognized parameter'), + ), + ); +}); + +test('isToolSupportError returns false for 400 without tool-related patterns', t => { + t.false( + isToolSupportError( + new Error('400 Bad Request: invalid input format'), + ), + ); + t.false(isToolSupportError(new Error('400 bad request: malformed JSON'))); +}); + +test('isToolSupportError detects direct tool not supported messages', t => { + t.true(isToolSupportError(new Error('tool not supported'))); + t.true(isToolSupportError(new Error('Tool not supported by model'))); + t.true(isToolSupportError(new Error('This tool is not supported'))); +}); + +test('isToolSupportError detects function not supported messages', t => { + t.true(isToolSupportError(new Error('function not supported'))); + t.true(isToolSupportError(new Error('Function calling not supported'))); + t.true(isToolSupportError(new Error('This function is not supported'))); +}); + +test('isToolSupportError detects tool unsupported messages', t => { + t.true(isToolSupportError(new Error('tool unsupported'))); + t.true(isToolSupportError(new Error('Tools are unsupported'))); +}); + +test('isToolSupportError detects function unsupported messages', t => { + t.true(isToolSupportError(new Error('function unsupported'))); + t.true(isToolSupportError(new Error('Function calling unsupported'))); +}); + +test('isToolSupportError detects invalid tool messages', t => { + t.true(isToolSupportError(new Error('invalid tool'))); + t.true(isToolSupportError(new Error('Invalid tool definition'))); +}); + +test('isToolSupportError detects invalid function messages', t => { + t.true(isToolSupportError(new Error('invalid function'))); + t.true(isToolSupportError(new Error('Invalid function definition'))); +}); + +test('isToolSupportError detects tool parameter invalid messages', t => { + t.true( + isToolSupportError(new Error('tool parameter invalid')), + ); + t.true( + isToolSupportError(new Error('Invalid tool parameters provided')), + ); +}); + +test('isToolSupportError detects function parameter invalid messages', t => { + t.true( + isToolSupportError(new Error('function parameter invalid')), + ); + t.true( + isToolSupportError( + new Error('Invalid function parameters in request'), + ), + ); +}); + +test('isToolSupportError is case-insensitive for direct patterns', t => { + t.true(isToolSupportError(new Error('TOOL NOT SUPPORTED'))); + t.true(isToolSupportError(new Error('Tool Not Supported'))); + t.true(isToolSupportError(new Error('tool NOT supported'))); +}); + +test('isToolSupportError detects Ollama-specific error with invalid character', t => { + t.true( + isToolSupportError( + new Error( + 'invalid character after top-level value', + ), + ), + ); + t.true( + isToolSupportError( + new Error( + 'invalid character after top-level value: unmarshal error', + ), + ), + ); +}); + +test('isToolSupportError requires both invalid character and top-level value patterns', t => { + t.false( + isToolSupportError(new Error('invalid character in string')), + ); + t.false( + isToolSupportError(new Error('after top-level value')), + ); +}); + +test('isToolSupportError detects mixed provider error messages', t => { + // OpenAI-style + t.true( + isToolSupportError( + new Error('400 Bad Request: invalid parameter tools'), + ), + ); + + // Anthropic-style + t.true( + isToolSupportError(new Error('function calling not supported')), + ); + + // Ollama-style + t.true( + isToolSupportError( + new Error( + 'invalid character after top-level value: unmarshal error', + ), + ), + ); + + // Generic + t.true( + isToolSupportError(new Error('tool parameter invalid')), + ); +}); + +test('isToolSupportError returns false for other error types', t => { + t.false( + isToolSupportError(new Error('404 Not Found: model not available')), + ); + t.false( + isToolSupportError( + new Error('401 Unauthorized: invalid API key'), + ), + ); + t.false( + isToolSupportError(new Error('500 Internal Server Error')), + ); + t.false( + isToolSupportError(new Error('Timeout: request took too long')), + ); + t.false( + isToolSupportError( + new Error('Connection refused: cannot reach server'), + ), + ); +}); + +test('isToolSupportError handles Error subclasses', t => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + + t.true( + isToolSupportError(new CustomError('tool not supported')), + ); + t.false( + isToolSupportError(new CustomError('some other error')), + ); +}); diff --git a/source/ai-sdk-client/error-handling/tool-error-detector.ts b/source/ai-sdk-client/error-handling/tool-error-detector.ts new file mode 100644 index 00000000..8cc73bf5 --- /dev/null +++ b/source/ai-sdk-client/error-handling/tool-error-detector.ts @@ -0,0 +1,60 @@ +/** + * Detects if an error indicates the model doesn't support tool calling + * + * This is used to automatically retry requests without tools when a model + * rejects tool definitions (common with some local models like deepseek-r1 via Ollama) + */ +export function isToolSupportError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + + // Pattern 1: 400 Bad Request with tool-related messages + if (message.includes('400') && message.includes('bad request')) { + const toolPatterns = [ + /tool/i, + /function/i, + /invalid.*parameter/i, + /unexpected.*field/i, + /unrecognized/i, + ]; + + for (const pattern of toolPatterns) { + if (pattern.test(message)) { + return true; + } + } + } + + // Pattern 2: Direct mentions of tool/function not supported + const directPatterns = [ + /tool.*not.*support/i, + /function.*not.*support/i, + /tool.*unsupported/i, + /function.*unsupported/i, + /invalid.*tool/i, + /invalid.*function/i, + /tool.*parameter.*invalid/i, + /function.*parameter.*invalid/i, + ]; + + for (const pattern of directPatterns) { + if (pattern.test(message)) { + return true; + } + } + + // Pattern 3: Ollama-specific error messages + // Ollama may return errors like: "invalid character 't' looking for beginning of value" + // when it encounters the 'tools' field in JSON + if ( + message.includes('invalid character') && + message.includes('after top-level value') + ) { + return true; + } + + return false; +} diff --git a/source/ai-sdk-client/tools/tool-prompt-formatter.spec.ts b/source/ai-sdk-client/tools/tool-prompt-formatter.spec.ts new file mode 100644 index 00000000..483b9e09 --- /dev/null +++ b/source/ai-sdk-client/tools/tool-prompt-formatter.spec.ts @@ -0,0 +1,158 @@ +import test from 'ava'; +import {jsonSchema, tool} from 'ai'; +import {formatToolsForPrompt} from './tool-prompt-formatter.js'; + +// Create test tools using AI SDK's tool() function +const createTestTool = ( + name: string, + description: string, + properties: Record, + required: string[], +) => { + return tool({ + description, + inputSchema: jsonSchema>({ + type: 'object', + properties, + required, + }), + execute: async () => 'test result', + }); +}; + +test('formatToolsForPrompt returns empty string for empty tools', t => { + const result = formatToolsForPrompt({}); + t.is(result, ''); +}); + +test('formatToolsForPrompt formats a single tool with description and parameters', t => { + const tools = { + read_file: createTestTool( + 'read_file', + 'Read a file from the filesystem', + { + path: {type: 'string', description: 'The path to the file'}, + encoding: {type: 'string', description: 'The file encoding'}, + }, + ['path'], + ), + }; + + const result = formatToolsForPrompt(tools); + + // Check header + t.true(result.includes('## AVAILABLE TOOLS')); + t.true(result.includes('XML block')); + + // Check tool name + t.true(result.includes('### read_file')); + + // Check description + t.true(result.includes('Read a file from the filesystem')); + + // Check parameters + t.true(result.includes('**Parameters:**')); + t.true(result.includes('`path`')); + t.true(result.includes('(required)')); + t.true(result.includes('`encoding`')); + t.true(result.includes('(optional)')); + + // Check example + t.true(result.includes('**Example:**')); + t.true(result.includes('')); + t.true(result.includes('value')); + t.true(result.includes('')); +}); + +test('formatToolsForPrompt formats multiple tools', t => { + const tools = { + read_file: createTestTool( + 'read_file', + 'Read a file', + {path: {type: 'string', description: 'File path'}}, + ['path'], + ), + write_file: createTestTool( + 'write_file', + 'Write a file', + { + path: {type: 'string', description: 'File path'}, + content: {type: 'string', description: 'File content'}, + }, + ['path', 'content'], + ), + }; + + const result = formatToolsForPrompt(tools); + + t.true(result.includes('### read_file')); + t.true(result.includes('### write_file')); + t.true(result.includes('Read a file')); + t.true(result.includes('Write a file')); +}); + +test('formatToolsForPrompt includes XML format instructions', t => { + const tools = { + test_tool: createTestTool( + 'test_tool', + 'Test tool', + {param: {type: 'string', description: 'A parameter'}}, + ['param'], + ), + }; + + const result = formatToolsForPrompt(tools); + + // Check for format instructions + t.true(result.includes('exact format')); + t.true(result.includes('')); + t.true(result.includes('value1')); + t.true(result.includes('')); + + // Check for important notes about XML format + t.true(result.includes('Do NOT use attributes')); + t.true(result.includes('')); +}); + +test('formatToolsForPrompt handles tools with no required parameters', t => { + const tools = { + list_files: createTestTool( + 'list_files', + 'List files in directory', + { + path: {type: 'string', description: 'Directory path'}, + recursive: {type: 'boolean', description: 'List recursively'}, + }, + [], // No required params + ), + }; + + const result = formatToolsForPrompt(tools); + + t.true(result.includes('### list_files')); + t.true(result.includes('(optional)')); + // Both should be optional + t.true(result.includes('`path`')); + t.true(result.includes('`recursive`')); +}); + +test('formatToolsForPrompt shows parameter types', t => { + const tools = { + test_tool: createTestTool( + 'test_tool', + 'Test various types', + { + str_param: {type: 'string', description: 'A string'}, + num_param: {type: 'number', description: 'A number'}, + bool_param: {type: 'boolean', description: 'A boolean'}, + }, + ['str_param'], + ), + }; + + const result = formatToolsForPrompt(tools); + + t.true(result.includes('(string)')); + t.true(result.includes('(number)')); + t.true(result.includes('(boolean)')); +}); diff --git a/source/ai-sdk-client/tools/tool-prompt-formatter.ts b/source/ai-sdk-client/tools/tool-prompt-formatter.ts new file mode 100644 index 00000000..cfc2fa85 --- /dev/null +++ b/source/ai-sdk-client/tools/tool-prompt-formatter.ts @@ -0,0 +1,122 @@ +import type {AISDKCoreTool} from '@/types/index'; + +/** + * Formats tool definitions for injection into the system prompt + * Used when native tool calling is disabled but we still want the model + * to be able to call tools via XML format + */ +export function formatToolsForPrompt( + tools: Record, +): string { + const toolNames = Object.keys(tools); + + if (toolNames.length === 0) { + return ''; + } + + let prompt = '\n\n## AVAILABLE TOOLS\n\n'; + prompt += + 'You have access to the following tools. To use a tool, output an XML block in this exact format:\n\n'; + prompt += + '```xml\n\nvalue1\nvalue2\n\n```\n\n'; + prompt += 'IMPORTANT:\n'; + prompt += '- Use the exact tool name as the outer XML tag\n'; + prompt += '- Each parameter should be its own XML tag inside\n'; + prompt += + '- Do NOT use attributes like or \n'; + prompt += '- You may call multiple tools in sequence\n\n'; + + for (const name of toolNames) { + const tool = tools[name]; + prompt += formatSingleTool(name, tool); + } + + return prompt; +} + +/** + * Formats a single tool definition + */ +function formatSingleTool(name: string, tool: AISDKCoreTool): string { + let output = `### ${name}\n\n`; + + // Extract description from tool + const description = extractDescription(tool); + if (description) { + output += `${description}\n\n`; + } + + // Extract and format parameters + const schema = extractInputSchema(tool); + if (schema && schema.properties) { + output += '**Parameters:**\n'; + + const properties = schema.properties as Record< + string, + {type?: string; description?: string} + >; + const required = (schema.required as string[]) || []; + + for (const [paramName, paramSchema] of Object.entries(properties)) { + const isRequired = required.includes(paramName); + const typeStr = paramSchema.type || 'any'; + const reqStr = isRequired ? '(required)' : '(optional)'; + const descStr = paramSchema.description || ''; + + output += `- \`${paramName}\` (${typeStr}) ${reqStr}: ${descStr}\n`; + } + + output += '\n'; + + // Add example usage + output += '**Example:**\n```xml\n'; + output += `<${name}>\n`; + for (const paramName of required.slice(0, 2)) { + // Show first 2 required params + output += `<${paramName}>value\n`; + } + output += `\n`; + output += '```\n\n'; + } + + return output; +} + +/** + * Extracts description from AI SDK tool + */ +function extractDescription(tool: AISDKCoreTool): string | undefined { + // AI SDK tools have description at the top level + if ('description' in tool && typeof tool.description === 'string') { + return tool.description; + } + return undefined; +} + +/** + * Extracts input schema from AI SDK tool + */ +function extractInputSchema( + tool: AISDKCoreTool, +): {properties?: unknown; required?: unknown} | undefined { + // AI SDK v6 tools use inputSchema (from jsonSchema()) + if ('inputSchema' in tool && tool.inputSchema) { + const schema = tool.inputSchema as {jsonSchema?: unknown}; + // jsonSchema() wraps the schema, so we need to unwrap it + if (schema.jsonSchema) { + return schema.jsonSchema as {properties?: unknown; required?: unknown}; + } + return schema as {properties?: unknown; required?: unknown}; + } + + // Fallback: check for parameters (older format) + if ('parameters' in tool && tool.parameters) { + const params = tool.parameters as {jsonSchema?: unknown}; + if (params.jsonSchema) { + return params.jsonSchema as {properties?: unknown; required?: unknown}; + } + return params as {properties?: unknown; required?: unknown}; + } + + return undefined; +} diff --git a/source/client-factory.ts b/source/client-factory.ts index a1c45ed1..a1452e08 100644 --- a/source/client-factory.ts +++ b/source/client-factory.ts @@ -127,6 +127,9 @@ function loadProviderConfigs(): AIProviderConfig[] { requestTimeout: provider.requestTimeout, socketTimeout: provider.socketTimeout, connectionPool: provider.connectionPool, + // Tool configuration + disableTools: provider.disableTools, + disableToolModels: provider.disableToolModels, config: { baseURL: provider.baseUrl, apiKey: provider.apiKey || 'dummy-key', diff --git a/source/types/config.ts b/source/types/config.ts index 3e566e02..742c9928 100644 --- a/source/types/config.ts +++ b/source/types/config.ts @@ -13,6 +13,9 @@ export interface AIProviderConfig { idleTimeout?: number; cumulativeMaxIdleTimeout?: number; }; + // Tool configuration + disableTools?: boolean; // Disable tools for entire provider + disableToolModels?: string[]; // List of model names to disable tools for config: { baseURL?: string; apiKey?: string; @@ -35,6 +38,9 @@ export interface ProviderConfig { idleTimeout?: number; cumulativeMaxIdleTimeout?: number; }; + // Tool configuration + disableTools?: boolean; // Disable tools for entire provider + disableToolModels?: string[]; // List of model names to disable tools for [key: string]: unknown; // Allow additional provider-specific config } @@ -52,6 +58,9 @@ export interface AppConfig { idleTimeout?: number; cumulativeMaxIdleTimeout?: number; }; + // Tool configuration + disableTools?: boolean; // Disable tools for entire provider + disableToolModels?: string[]; // List of model names to disable tools for [key: string]: unknown; // Allow additional provider-specific config }[];