UNPKG

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