@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
261 lines • 13 kB
JavaScript
import { generateText, InvalidToolInputError, NoSuchToolError, stepCountIs, ToolCallRepairError, } from 'ai';
import { MAX_TOOL_STEPS } from '../../constants.js';
import { endMetrics, formatMemoryUsage, generateCorrelationId, getCorrelationId, getLogger, startMetrics, withNewCorrelationContext, } from '../../utils/logging/index.js';
import { getSafeMemory } from '../../utils/logging/safe-process.js';
import { convertToModelMessages } from '../converters/message-converter.js';
import { convertAISDKToolCalls } 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, } from './streaming-handler.js';
/**
* Main chat handler - orchestrates the entire chat flow
*/
export async function handleChat(params) {
const { model, currentModel, providerConfig, messages, tools, callbacks, signal, maxRetries, skipTools = false, modeOverrides, } = params;
const logger = getLogger();
// Check if already aborted before starting
if (signal?.aborted) {
logger.debug('Chat request already aborted');
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: shouldDisableTools ? 0 : Object.keys(tools).length,
correlationId,
provider: providerConfig.name,
});
return await withNewCorrelationContext(async (_context) => {
try {
// Apply non-interactive mode overrides to tool approval
// In non-interactive mode, tools in the allowList should bypass needsApproval
let effectiveTools = tools;
if (modeOverrides?.nonInteractiveMode &&
modeOverrides.nonInteractiveAlwaysAllow.length > 0) {
const allowSet = new Set(modeOverrides.nonInteractiveAlwaysAllow);
effectiveTools = Object.fromEntries(Object.entries(tools).map(([name, toolDef]) => {
if (allowSet.has(name)) {
// Override needsApproval to false for allowed tools
return [
name,
{ ...toolDef, needsApproval: false },
];
}
return [name, toolDef];
}));
}
// Tools are already in AI SDK format - use directly
const aiTools = shouldDisableTools
? undefined
: Object.keys(effectiveTools).length > 0
? effectiveTools
: 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(messagesWithToolPrompt);
logger.debug('AI SDK request prepared', {
messageCount: modelMessages.length,
hasTools: !!aiTools,
toolCount: aiTools ? Object.keys(aiTools).length : 0,
});
// Tools with needsApproval: false auto-execute in the SDK's loop
// Tools with needsApproval: true cause the SDK to stop for approval
// stopWhen controls when the tool loop stops (max MAX_TOOL_STEPS steps)
const result = await generateText({
model,
messages: modelMessages,
tools: aiTools,
abortSignal: signal,
maxRetries,
stopWhen: stepCountIs(MAX_TOOL_STEPS),
onStepFinish: createOnStepFinishHandler(callbacks),
prepareStep: createPrepareStepHandler(),
headers: providerConfig.config.headers,
});
const fullText = result.text;
logger.debug('AI SDK response received', {
responseLength: fullText.length,
hasToolCalls: result.toolCalls.length > 0,
toolCallCount: result.toolCalls.length,
stepCount: result.steps.length,
});
// Send the complete text to the callback
if (fullText) {
callbacks.onToken?.(fullText);
}
// Without execute functions on tools, the SDK doesn't auto-execute anything.
// All tool calls are returned for us to handle (parallel execution, confirmation, etc.).
const toolCalls = result.toolCalls.length > 0
? convertAISDKToolCalls(result.toolCalls)
: [];
const content = fullText;
// Calculate performance metrics
const finalMetrics = endMetrics(metrics);
logger.info('Chat request completed successfully', {
model: currentModel,
duration: `${finalMetrics.duration.toFixed(2)}ms`,
responseLength: content.length,
toolCallsFound: toolCalls.length,
memoryDelta: formatMemoryUsage(finalMetrics.memoryUsage || getSafeMemory()),
correlationId,
provider: providerConfig.name,
});
callbacks.onFinish?.();
return {
choices: [
{
message: {
role: 'assistant',
content,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
},
},
],
toolsDisabled: shouldDisableTools,
};
}
catch (error) {
// Calculate performance metrics even for errors
const finalMetrics = endMetrics(metrics);
// Check if this was a user-initiated cancellation
if (error instanceof Error && error.name === 'AbortError') {
logger.info('Chat request cancelled by user', {
model: currentModel,
duration: `${finalMetrics.duration.toFixed(2)}ms`,
correlationId,
provider: providerConfig.name,
});
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', {
toolName: error.toolName,
model: currentModel,
correlationId,
provider: providerConfig.name,
});
// 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.`;
throw new Error(errorMessage);
}
// Handle tool-specific errors - InvalidToolInputError
if (error instanceof InvalidToolInputError) {
logger.error('Invalid tool input', {
toolName: error.toolName,
model: currentModel,
correlationId,
provider: providerConfig.name,
validationError: error.message,
});
// Provide clear validation error
throw new Error(`Invalid arguments for tool "${error.toolName}": ${error.message}`);
}
// Handle tool-specific errors - ToolCallRepairError
if (error instanceof ToolCallRepairError) {
logger.error('Tool call repair failed', {
toolName: error.originalError.toolName,
model: currentModel,
correlationId,
provider: providerConfig.name,
repairError: error.message,
});
// Fall through to general error handling
// Don't throw here - let the general handler provide context
}
// Log the error with performance metrics
logger.error('Chat request failed', {
model: currentModel,
duration: `${finalMetrics.duration.toFixed(2)}ms`,
error: error instanceof Error ? error.message : error,
errorName: error instanceof Error ? error.name : 'Unknown',
errorType: error?.constructor?.name || 'Unknown',
correlationId,
provider: providerConfig.name,
memoryDelta: formatMemoryUsage(finalMetrics.memoryUsage || getSafeMemory()),
});
// AI SDK wraps errors in NoOutputGeneratedError with no useful cause
// Check if it's a cancellation without an underlying API error
if (error instanceof Error &&
(error.name === 'AI_NoOutputGeneratedError' ||
error.message.includes('No output generated'))) {
// Check if there's an underlying RetryError with the real cause
const rootError = extractRootError(error);
if (rootError === error) {
// No underlying error - check if user actually cancelled
if (signal?.aborted) {
throw new Error('Operation was cancelled');
}
// Model returned empty response without cancellation
throw new Error('Model returned empty response. This may indicate the model is not responding correctly or the prompt was unclear.');
}
// There's a real error underneath, parse it
const userMessage = parseAPIError(rootError);
throw new Error(userMessage);
}
// Parse any other error (including RetryError and APICallError)
const userMessage = parseAPIError(error);
throw new Error(userMessage);
}
}, correlationId); // End of withNewCorrelationContext
}
//# sourceMappingURL=chat-handler.js.map