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