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

372 lines 14.5 kB
// source/utils/response-formatter.ts // ============================================================================ // NORMALIZATION FUNCTIONS // ============================================================================ /** * Main entry point for normalizing LLM responses * * This function handles all possible LLM response types: * - Plain strings (most common) * - JSON objects (with/without tool_calls field) * - Arrays (line-based content) * - Null/undefined (error responses) * - Mixed content (text + tool calls embedded) * * @param response - The raw response from the LLM (can be any type) * @param options - Configuration options * @returns A normalized response with content and extracted tool calls */ export async function normalizeLLMResponse(response, options = {}) { const { preserveRawTypes = false, // If true, keeps object structure instead of JSON.stringify allowMixedContent = true, // If true, extracts tool calls from mixed content } = options; // Step 1: Detect response type and convert to string for parsing const { content, detectedFormat } = convertToProcessableString(response); // Step 2: Extract tool calls from the content const { toolCalls, malformedError } = await extractToolCalls(content, { allowMixedContent, detectedFormat, }); // Step 3: Detect if response is malformed const isMalformed = !toolCalls.length && hasMalformedPatterns(content); // Step 4: Build metadata for debugging and strategy selection // Check for JSON blocks in content - only match tool call patterns, not plain JSON objects const hasCodeBlocks = /```/.test(content); const hasJSONBlocks = /"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{/.test(content); const metadata = buildMetadata(content, { hasCodeBlocks, hasXMLTags: /<[^>]+>/.test(content), hasJSONBlocks, detectedFormat, malformedError, isMalformed, }, toolCalls.length > 0); return { raw: preserveRawTypes ? response : undefined, // Only preserve if requested content, toolCalls, metadata, }; } /** * Converts any LLM response type to a string suitable for parsing * * Handles the following cases: * 1. string → string (identity, already processable) * 2. object → JSON.stringify (structured data) * 3. array → JSON.stringify (array data) * 4. null/undefined → empty string (error handling) * 5. number/boolean → String() (primitive types) * * This conversion is NECESSARY because our parsers expect strings. * The type preservation happens later in the ToolCall structure. * * @param response - Raw response from LLM (any type) * @returns Object with processable string content and detected format */ function convertToProcessableString(response) { let content; let detectedFormat = 'plain'; // CASE 1: Plain string response (most common) if (typeof response === 'string') { content = response.trim(); // Check if it looks like a JSON structure if (content.startsWith('{') && content.endsWith('}')) { detectedFormat = 'json'; // Don't JSON.stringify a string that's already JSON - just use it as-is // The parser will parse it correctly } // Check if it looks like XML else if (content.startsWith('<') && content.endsWith('>')) { detectedFormat = 'xml'; // Don't JSON.stringify a string that's already XML - just use it as-is // The parser will parse it correctly } // Check for mixed content (text + tool calls) else if (hasEmbeddedToolCallPatterns(content)) { detectedFormat = 'mixed'; // Don't JSON.stringify - keep as plain string for parser to handle } } // CASE 2: JSON object response (structured data) else if (response !== null && typeof response === 'object') { // Check if it has a tool_calls field (AI SDK format) if ('tool_calls' in response || 'function' in response) { detectedFormat = 'json'; // If it's already structured, we might not need JSON.stringify // But for consistency with parsers, we still convert to string content = JSON.stringify(response); } // Regular object (not a tool_calls response) else { detectedFormat = 'json'; content = JSON.stringify(response); } } // CASE 3: Array response (line-based content) else if (Array.isArray(response)) { detectedFormat = 'mixed'; content = response.join('\n').trim(); } // CASE 4: Primitives (number, boolean, null, undefined) else { // Convert primitives to strings content = String(response); // Empty string if null or undefined if (response === null || response === undefined) { content = ''; } } return { content, detectedFormat }; } /** * Extracts tool calls from normalized content * * This function uses the unified parser to extract tool calls from: * - Plain text with embedded JSON/XML tool calls * - JSON objects with tool_calls field * - Mixed content (text + tool calls) * * @param content - Normalized content string * @param options - Extraction options * @returns Object with extracted tool calls and any malformed error */ async function extractToolCalls(content, options) { const { allowMixedContent } = options; // If no content, no tool calls if (!content) { return { toolCalls: [] }; } // Try XML parser try { const { XMLToolCallParser } = await import('../tool-calling/xml-parser.js'); // Check for malformed XML first const malformedError = XMLToolCallParser.detectMalformedToolCall(content); if (malformedError) { return { toolCalls: [], malformedError: malformedError.error }; } // Check if has XML tool calls if (XMLToolCallParser.hasToolCalls(content)) { const parsedCalls = XMLToolCallParser.parseToolCalls(content); const toolCalls = XMLToolCallParser.convertToToolCalls(parsedCalls); if (toolCalls.length > 0) { return { toolCalls }; } } } catch (error) { // XML parsing failed, continue console.warn('XML parsing failed:', error); } // CASE 3: Mixed content - use unified parser if (allowMixedContent) { try { const { parseToolCalls } = await import('../tool-calling/tool-parser.js'); const result = parseToolCalls(content); if (result.success && result.toolCalls.length > 0) { return { toolCalls: result.toolCalls }; } if (!result.success) { return { toolCalls: [], malformedError: result.error }; } } catch (error) { // Parsing failed, return empty tool calls console.warn('Mixed content parsing failed:', error); } } // No tool calls found return { toolCalls: [] }; } /** * Detects if content contains malformed patterns * * Logic: * 1. If it parses as valid JSON via try/catch, it is NOT malformed. * 2. If parsing fails, check for known "broken tool call" signatures. * * @param content - Content to check * @returns True if malformed patterns detected */ function hasMalformedPatterns(content) { const trimmed = content.trim(); // 1. Sanity Check: Empty content is not "malformed", just empty. if (!trimmed) { return false; } // 2. THE SAFETY CHECK: Trust the Runtime (JSON.parse) // We check if it starts and ends with braces to identify it as a candidate for an Object. if (trimmed.startsWith('{') && trimmed.endsWith('}')) { try { // CRASH PROTECTION: If this succeeds, the JSON is valid. // Therefore, it cannot be "malformed". JSON.parse(trimmed); return false; } catch (_e) { // It crashed. It might be malformed. // Fall through to regex checks below. } } // 3. Check for specific broken tool call patterns. const malformedJSONPatterns = [ /^\s*\{\s*"name"\s*:\s*"[^"]+"\s*\}\s*$/, // name only, no arguments /^\s*\{\s*"arguments"\s*:\s*\}\s*$/, // arguments only, no name /"arguments"\s*:\s*null\s*([,}\s]*$)/s, // arguments as JSON null (followed by comma, brace, or end) /^\s*\{\s*"arguments"\s*:\s*\[\s*\]\s*\}$/, // arguments as empty array /^\s*\{\s*"arguments"\s*:\s*""\s*\}$/, // arguments as empty string ]; for (const pattern of malformedJSONPatterns) { if (pattern.test(trimmed)) { return true; } } // 4. Check for malformed XML patterns const malformedXMLPatterns = [ /\[(?:tool_use|Tool):\s*(\w+)\]/i, // [tool_use: name] syntax /<function=(\w+)>/, // <function=name> syntax /<parameter=(\w+)>/, // <parameter=name> syntax ]; for (const pattern of malformedXMLPatterns) { if (pattern.test(content)) { return true; } } return false; } /** * Builds metadata object for debugging and strategy selection * * @param content - Normalized content * @param partialMetadata - Partial metadata from earlier detection * @param hasToolCalls - Whether tool calls were actually extracted * @returns Complete metadata object */ function buildMetadata(content, partialMetadata, hasToolCalls) { // Check if content looks like JSON (has { and }) // Check if content looks like XML (has < and >) // Check if content has code blocks const hasCodeBlocks = /```/.test(content); // Check if content has XML tags const hasXMLTags = /<[^>]+>/.test(content); // Check if content has JSON blocks (tool call patterns only, not plain JSON objects) const hasJSONBlocks = /"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{/.test(content); return { hasCodeBlocks: partialMetadata.hasCodeBlocks ?? hasCodeBlocks, hasXMLTags: partialMetadata.hasXMLTags ?? hasXMLTags, hasJSONBlocks: partialMetadata.hasJSONBlocks ?? hasJSONBlocks, confidence: determineConfidence({ ...partialMetadata, hasCodeBlocks, hasXMLTags, hasJSONBlocks, }, hasToolCalls), detectedFormat: partialMetadata.detectedFormat ?? 'unknown', isMalformed: partialMetadata.isMalformed ?? hasMalformedPatterns(content), malformedError: partialMetadata.malformedError, }; } /** * Determines confidence level in the response type classification * * Confidence levels: * - HIGH: Clear format detected (JSON with tool_calls, XML with tool calls) * - MEDIUM: Unclear format (plain text with possible tool calls) * - LOW: No format detected (plain text with no tool calls) * * @param metadata - Metadata from response * @param hasToolCalls - Whether tool calls were actually extracted * @returns Confidence level */ function determineConfidence(metadata, hasToolCalls) { // High confidence if we actually extracted tool calls if (hasToolCalls) { return 'high'; } // If it is explicitly malformed, confidence is LOW if (metadata.isMalformed) { return 'low'; } // Medium confidence if it looks like code/data, but contained no valid tools if (metadata.detectedFormat === 'json' || metadata.detectedFormat === 'xml' || metadata.hasCodeBlocks || metadata.hasJSONBlocks || metadata.hasXMLTags) { return 'medium'; } // LOW: Plain text with no format indicators return 'low'; } /** * Checks if content contains embedded tool call patterns * * @param content - Content to check * @returns True if embedded patterns detected */ function hasEmbeddedToolCallPatterns(content) { // JSON tool call patterns (allow text before the pattern) const jsonPatterns = [ /\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{/s, /\s*\{"name":\s*"([^"]+)",\s*"arguments":\s*\{/s, ]; // XML tool call patterns const xmlPatterns = [/<(\w+)>(.*?)<\/\1>/s]; for (const pattern of [...jsonPatterns, ...xmlPatterns]) { if (pattern.test(content)) { return true; } } return false; } // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ /** * Formats a normalized response for debugging * * @param response - Normalized response to format * @returns Formatted string for debugging */ export function formatNormalizedResponse(response) { return [ `=== Normalized Response ===`, `Raw Type: ${response.raw !== undefined ? typeof response.raw : 'undefined'}`, `Content Type: ${typeof response.content}`, `Content Length: ${response.content.length} chars`, `Detected Format: ${response.metadata.detectedFormat}`, `Confidence: ${response.metadata.confidence}`, `Has Code Blocks: ${response.metadata.hasCodeBlocks}`, `Has XML Tags: ${response.metadata.hasXMLTags}`, `Has JSON Blocks: ${response.metadata.hasJSONBlocks}`, `Is Malformed: ${response.metadata.isMalformed}`, `Tool Calls: ${response.toolCalls.length}`, ``, `Content Preview:`, response.content.slice(0, 500) + (response.content.length > 500 ? '...' : ''), ``, `Tool Calls:`, response.toolCalls .map(tc => ` - ${tc.function.name}: ${JSON.stringify(tc.function.arguments)}`) .join('\n'), ].join('\n'); } /** * Checks if a response is complete and ready for processing * * A response is complete if: * 1. It has content * 2. It has low confidence (means no tool calls were found, so no further processing needed) * 3. It's not malformed * * @param response - Normalized response to check * @returns True if response is complete */ export function isResponseComplete(response) { // Response is complete if it has content and is not malformed return (response.content.length > 0 && !response.metadata.isMalformed && response.metadata.confidence !== 'low'); } //# sourceMappingURL=response-formatter.js.map