@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
219 lines • 9.6 kB
JavaScript
import { ensureString } from '../utils/type-helpers.js';
/**
* Internal JSON tool call parser
* Note: This is now an internal utility. Use tool-parser.ts for public API.
* Type-preserving: Preserves object types in memory while converting to string for processing
*/
/**
* Detects malformed JSON tool call attempts and returns error details
* Returns null if no malformed tool calls detected
* Type-preserving: Accepts unknown type, converts to string for processing
*/
export function detectMalformedJSONToolCall(content) {
// Type guard: ensure content is string for processing operations
// BUT original type is preserved in memory via the ToolCall structure
const contentStr = ensureString(content);
// Check for incomplete JSON structures
// FIX: Anchor to line start (?:^|\n) to avoid matching inline text like "I tried {"name":...}"
const patterns = [
{
// Incomplete JSON with name but missing arguments
// Anchored to line start or after newline
regex: /(?:^|\n)\s*\{\s*"name"\s*:\s*"[^"]+"\s*,?\s*\}/,
error: 'Incomplete tool call: missing "arguments" field',
hint: 'Tool calls must include both "name" and "arguments" fields',
},
{
// Incomplete JSON with arguments but missing name
// Anchored to line start or after newline
regex: /(?:^|\n)\s*\{\s*"arguments"\s*:\s*\{[^}]*\}\s*\}/,
error: 'Incomplete tool call: missing "name" field',
hint: 'Tool calls must include both "name" and "arguments" fields',
},
{
// Malformed arguments (not an object)
// Anchored to line start or after newline
regex: /(?:^|\n)\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*"[^"]*"\s*\}/,
error: 'Invalid tool call: "arguments" must be an object, not a string',
hint: 'Use {"name": "tool_name", "arguments": {...}} format',
},
];
for (const pattern of patterns) {
const match = contentStr.match(pattern.regex);
if (match) {
return {
error: pattern.error,
examples: getCorrectJSONFormatExamples(pattern.hint),
};
}
}
return null;
}
/**
* Generates correct format examples for JSON error messages
*/
function getCorrectJSONFormatExamples(_specificHint) {
return `Please use the native tool calling format provided by the system. The tools are already available to you - call them directly using the function calling interface.`;
}
/**
* Parses JSON-formatted tool calls from content
* Type-preserving: Preserves object types in memory while converting to string for processing
* This is an internal function - use tool-parser.ts for public API
*/
export function parseJSONToolCalls(content) {
// Convert to string for processing (this is done by the Formatter before calling this)
const contentStr = ensureString(content);
const extractedCalls = [];
let trimmedContent = contentStr.trim();
// Handle markdown code blocks
const codeBlockMatch = trimmedContent.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
if (codeBlockMatch && codeBlockMatch[1]) {
trimmedContent = codeBlockMatch[1].trim();
}
// Try to parse entire content as single JSON tool call
if (trimmedContent.startsWith('{') && trimmedContent.endsWith('}')) {
// Skip empty or nearly empty JSON objects
if (trimmedContent === '{}' || trimmedContent.replace(/\s/g, '') === '{}') {
return extractedCalls;
}
try {
const parsed = JSON.parse(trimmedContent);
if (parsed.name && parsed.arguments !== undefined) {
// Reject null, undefined, or non-object arguments
if (parsed.arguments === null ||
typeof parsed.arguments !== 'object' ||
Array.isArray(parsed.arguments)) {
return extractedCalls;
}
const toolCall = {
id: `call_${Date.now()}`,
function: {
name: parsed.name || '',
arguments: parsed.arguments, // Type preserved in memory!
},
};
extractedCalls.push(toolCall);
return extractedCalls;
}
}
catch {
// Failed to parse - will be caught by malformed detection
}
}
// Look for standalone JSON blocks in the content (multiline without code blocks)
const jsonBlockRegex = /\{\s*\n\s*"name":\s*"([^"]+)",\s*\n\s*"arguments":\s*\{[\s\S]*?\}\s*\n\s*\}/g;
let jsonMatch;
while ((jsonMatch = jsonBlockRegex.exec(contentStr)) !== null) {
try {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed.name && parsed.arguments !== undefined) {
// Reject null, undefined, or non-object arguments
if (parsed.arguments === null ||
typeof parsed.arguments !== 'object' ||
Array.isArray(parsed.arguments)) {
continue;
}
const toolCall = {
id: `call_${Date.now()}_${extractedCalls.length}`,
function: {
name: parsed.name || '',
arguments: parsed.arguments, // Type preserved in memory!
},
};
extractedCalls.push(toolCall);
}
}
catch {
// Failed to parse - will be caught by malformed detection
}
}
// Look for embedded tool calls using regex patterns
const toolCallPatterns = [
/\{"name":\s*"([^"]+)",\s*"arguments":\s*(\{[\s\S]*?\})\}/g,
];
for (const pattern of toolCallPatterns) {
let match;
while ((match = pattern.exec(contentStr)) !== null) {
const [, name, argsStr] = match;
try {
let args = null;
// Only parse if arguments is a JSON object
if (argsStr && argsStr.startsWith('{')) {
const parsed = JSON.parse(argsStr || '{}');
// Ensure arguments is a non-null object (not null, undefined, primitive, or array)
if (parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed)) {
args = parsed; // Type preserved in memory!
}
}
// Only add tool call if we have a valid object
if (args !== null) {
extractedCalls.push({
id: `call_${Date.now()}_${extractedCalls.length}`,
function: {
name: name || '',
arguments: args, // Type preserved in memory!
},
});
}
}
catch {
// Failed to parse - skip this tool call
}
}
}
return extractedCalls;
}
/**
* Cleans content by removing tool call JSON blocks
* Type-preserving: Accepts unknown type, converts to string for processing
* This is an internal function - use tool-parser.ts for public API
*/
export function cleanJSONToolCalls(content, toolCalls) {
// Type guard: ensure content is string for processing operations
// BUT original type is preserved in memory via the ToolCall structure
const contentStr = ensureString(content);
if (toolCalls.length === 0)
return contentStr;
let cleanedContent = contentStr;
// Handle markdown code blocks that contain only tool calls
const codeBlockRegex = /```(?:json)?\s*\n?([\s\S]*?)\n?```/g;
cleanedContent = cleanedContent.replace(codeBlockRegex, (match, blockContent) => {
const trimmedBlock = blockContent.trim();
// Check if this block contains a tool call that we parsed
try {
const parsed = JSON.parse(trimmedBlock);
if (parsed.name && parsed.arguments !== undefined) {
// This code block contains only a tool call, remove the entire block
return '';
}
}
catch {
// Not valid JSON, keep the code block
}
// Keep the code block as-is if it doesn't contain a tool call
return match;
});
// Remove JSON blocks that were parsed as tool calls (for non-code-block cases)
const toolCallPatterns = [
/\{\s*\n\s*"name":\s*"([^"]+)",\s*\n\s*"arguments":\s*\{[\s\S]*?\}\s*\n\s*\}/g, // Multiline JSON blocks
/\{"name":\s*"([^"]+)",\s*"arguments":\s*(\{[\s\S]*?\})\}/g, // Consolidated inline pattern
];
for (const pattern of toolCallPatterns) {
cleanedContent = cleanedContent.replace(pattern, '');
}
// Clean up whitespace artifacts left by removed tool calls
cleanedContent = cleanedContent
// Remove trailing whitespace from each line
.replace(/[ \t]+$/gm, '')
// Collapse multiple spaces (but not at start of line for indentation)
.replace(/([^ \t\n]) {2,}/g, '$1 ')
// Remove lines that are only whitespace
.replace(/^[ \t]+$/gm, '')
// Collapse 2+ consecutive blank lines to a single blank line
.replace(/\n{3,}/g, '\n\n')
.trim();
return cleanedContent;
}
//# sourceMappingURL=json-parser.js.map