Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion agents.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 73 additions & 6 deletions source/ai-sdk-client/chat/chat-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -47,6 +49,7 @@ export interface ChatHandlerParams {
callbacks: StreamCallbacks;
signal?: AbortSignal;
maxRetries: number;
skipTools?: boolean; // Track if we're retrying without tools
}

/**
Expand All @@ -64,6 +67,7 @@ export async function handleChat(
callbacks,
signal,
maxRetries,
skipTools = false,
} = params;
const logger = getLogger();

Expand All @@ -73,25 +77,72 @@ 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,
});

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,
Expand Down Expand Up @@ -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', {
Expand All @@ -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);
}
Expand All @@ -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}`,
);
}

Expand Down
225 changes: 225 additions & 0 deletions source/ai-sdk-client/error-handling/tool-error-detector.spec.ts
Original file line number Diff line number Diff line change
@@ -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')),
);
});
Loading