@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
294 lines • 14.8 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 { convertToModelMessages } from '../converters/message-converter.js';
import { convertAISDKToolCalls, generateToolCallId, getToolResultOutput, } 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';
import { processXMLToolCalls } from './tool-processor.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, } = 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 {
// Tools are already in AI SDK format - use directly
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(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 loop
// Tools with needsApproval: true cause interruptions for manual 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), // Allow up to MAX_TOOL_STEPS tool execution steps
// Can be used to add custom logging, metrics, or step tracking
onStepFinish: createOnStepFinishHandler(callbacks),
prepareStep: createPrepareStepHandler(),
headers: providerConfig.config.headers,
});
// Get the full text from the result
const fullText = result.text;
logger.debug('AI SDK response received', {
responseLength: fullText.length,
hasToolCalls: !!(result.toolCalls && result.toolCalls.length > 0),
toolCallCount: result.toolCalls?.length || 0,
});
// Send the complete text to the callback
if (fullText) {
callbacks.onToken?.(fullText);
}
// Get tool calls from result
const toolCallsResult = result.toolCalls;
// Extract auto-executed assistant messages and tool results from steps
// These need to be added to the messages array so usage tracking can count them
const autoExecutedMessages = [];
const steps = result.steps;
for (const step of steps) {
if (step.toolCalls &&
step.toolResults &&
step.toolCalls.length === step.toolResults.length) {
// This step had tool calls that were auto-executed
// Add the assistant message with tool_calls
const stepToolCalls = convertAISDKToolCalls(step.toolCalls);
autoExecutedMessages.push({
role: 'assistant',
content: step.text || '',
tool_calls: stepToolCalls,
});
// Add the tool result messages
step.toolCalls.forEach((toolCall, idx) => {
const toolResult = step.toolResults[idx];
const resultStr = getToolResultOutput(toolResult.output);
autoExecutedMessages.push({
role: 'tool',
content: resultStr,
tool_call_id: toolCall.toolCallId || generateToolCallId(),
name: toolCall.toolName,
});
});
}
}
// Extract tool calls
const toolCalls = [];
if (toolCallsResult && toolCallsResult.length > 0) {
logger.debug('Processing tool calls from response', {
toolCallCount: toolCallsResult.length,
});
for (const toolCall of toolCallsResult) {
const tc = convertAISDKToolCalls([toolCall])[0];
toolCalls.push(tc);
logger.debug('Tool call processed', {
toolName: tc.function.name,
hasArguments: !!tc.function.arguments,
});
// Note: onToolCall already fired in onStepFinish - no need to call again
}
}
// Check for XML tool calls if no native ones
let content = fullText;
const xmlResult = await processXMLToolCalls(content, tools, callbacks);
if (xmlResult.toolCalls.length > 0) {
toolCalls.push(...xmlResult.toolCalls);
content = xmlResult.cleanedContent;
}
// 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 || process.memoryUsage()),
correlationId,
provider: providerConfig.name,
});
callbacks.onFinish?.();
return {
choices: [
{
message: {
role: 'assistant',
content,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
},
},
],
// Include auto-executed messages so they can be added to message history
autoExecutedMessages: autoExecutedMessages.length > 0 ? autoExecutedMessages : undefined,
};
}
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 || process.memoryUsage()),
});
// 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