UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

348 lines (347 loc) • 15.1 kB
import { logger } from "./logger.js"; import { levenshtein, MAX_COMPARABLE_LENGTH } from "./stringDistance.js"; /** * Create an `experimental_repairToolCall` handler for streamText/generateText. * Fully dynamic — reads the tool schema at repair time, no configuration needed. */ export function createToolCallRepair() { return async ({ toolCall, tools, inputSchema, error }) => { // Import error classes lazily to avoid circular deps at module level const { NoSuchToolError: NoSuchTool, InvalidToolInputError: InvalidInput } = await import("./generationErrors.js"); if (NoSuchTool.isInstance(error)) { return repairToolName(toolCall, Object.keys(tools)); } if (InvalidInput.isInstance(error)) { try { const schema = await inputSchema({ toolName: toolCall.toolName }); return repairToolInput(toolCall, schema); } catch { // inputSchema() failed — can't repair without schema return null; } } return null; }; } // ─── Tool Name Repair ────────────────────────────────────────────── /** * Match a possibly-misspelled tool name against a list of available tool * names. Strategies (in order): case-insensitive exact → unambiguous * substring → Levenshtein. * * Pulled out of `repairToolName` so the same matching policy can be reused * outside the AI-SDK generation loop — `experimental_repairToolCall` only * runs inside `streamText`/`generateText`, so a name typo at a direct MCP * execution boundary (`NeuroLink.executeExternalMCPTool`) previously had no * recovery at all. This function is pure name-matching: no `LanguageModelV3ToolCall`, * no logging, so callers with a different call shape can reuse it directly. */ export function resolveToolName(calledName, availableTools) { // Guard: empty or whitespace-only tool name cannot be meaningfully repaired if (!calledName || calledName.trim().length === 0) { return null; } // 1. Case-insensitive exact match const ciMatch = availableTools.find((t) => t.toLowerCase() === calledName.toLowerCase()); if (ciMatch) { return { name: ciMatch, strategy: "case" }; } // 2. Substring match: "search_file" is substring of "search_files" or vice versa. // Only accept when exactly one tool matches to avoid ambiguous repairs. const calledLower = calledName.toLowerCase(); const subCandidates = availableTools.filter((t) => { const tLower = t.toLowerCase(); return tLower.includes(calledLower) || calledLower.includes(tLower); }); if (subCandidates.length === 1) { return { name: subCandidates[0], strategy: "substring" }; } // 3. Levenshtein distance — accept if normalized distance < 0.3 // Compare by normalized score (not raw edits) so length differences don't skew selection. // `levenshtein` throws rather than truncates past MAX_COMPARABLE_LENGTH (see // stringDistance.ts), so an over-limit name is skipped here instead of // compared on a truncated prefix — a called name and a tool name that // merely share a long-enough prefix must never tie at distance 0 and // resolve to the wrong tool. The guard checks the lowercased operands, since // those are what get compared and lowercasing can lengthen a string ("İ"). let bestMatch = null; let bestNormalized = Infinity; if (calledLower.length <= MAX_COMPARABLE_LENGTH) { for (const t of availableTools) { const tLower = t.toLowerCase(); if (tLower.length > MAX_COMPARABLE_LENGTH) { continue; } const dist = levenshtein(calledLower, tLower); const maxLen = Math.max(calledName.length, t.length); const normalized = maxLen === 0 ? 0 : dist / maxLen; if (normalized < 0.3 && normalized < bestNormalized) { bestNormalized = normalized; bestMatch = t; } } } if (bestMatch) { return { name: bestMatch, strategy: "levenshtein", score: bestNormalized }; } return null; } /** * Rank every available tool name by similarity to `calledName` (ascending * normalized Levenshtein distance) and return the closest `limit`. * * Used to build the candidate list on `ExternalMcpToolNotFoundError` when * `resolveToolName` found no unambiguous match — unlike `resolveToolName`, * this makes no accept/reject judgment, it just orders what is available so * a caller (human or AI) can pick the right name themselves. */ export function rankToolNameCandidates(calledName, availableTools, limit = 5) { const calledLower = calledName.toLowerCase(); const calledWithinLimit = calledLower.length <= MAX_COMPARABLE_LENGTH; // `levenshtein` throws past MAX_COMPARABLE_LENGTH instead of truncating, so // an over-limit operand is scored as "furthest away" rather than compared // on a truncated (and potentially falsely-tied) prefix. This still only // orders the list — no candidate is dropped — matching this function's // "no accept/reject judgment" contract. const distanceTo = (name) => { const nameLower = name.toLowerCase(); if (!calledWithinLimit || nameLower.length > MAX_COMPARABLE_LENGTH) { return Number.POSITIVE_INFINITY; } return levenshtein(calledLower, nameLower); }; // Precompute once per tool (not per comparison), and compare with an // explicit equality check — two Infinity distances (both over-limit) would // otherwise subtract to NaN, an invalid sort-comparator result. return availableTools .map((name) => ({ name, distance: distanceTo(name) })) .sort((a, b) => (a.distance === b.distance ? 0 : a.distance - b.distance)) .slice(0, limit) .map((entry) => entry.name); } /** * Thrown at a direct MCP execution boundary (`NeuroLink.executeExternalMCPTool`) * when `resolveToolName` cannot find an unambiguous match for a requested * tool name against a server's discovered tools. Distinct from the plain * `Error` that `ToolDiscoveryService.executeTool` throws deeper in the stack * for the same condition, so callers can distinguish "no match — here are * the closest names" from every other execution failure programmatically, * instead of parsing a message string. */ export class ExternalMcpToolNotFoundError extends Error { /** The tool name that was requested and could not be resolved. */ requestedName; /** The server the tool was requested against. */ serverId; /** Closest available tool names on that server, capped (see `rankToolNameCandidates`). */ candidates; constructor(requestedName, serverId, candidates) { const candidateList = candidates.length > 0 ? candidates.join(", ") : "(none registered)"; super(`Tool '${requestedName}' not found for server '${serverId}'. Closest available: ${candidateList}`); this.name = "ExternalMcpToolNotFoundError"; this.requestedName = requestedName; this.serverId = serverId; this.candidates = candidates; } } /** * Attempt to match a wrong tool name against available tool names and * produce a repaired `LanguageModelV3ToolCall` for the AI-SDK generation * path. Thin wrapper around `resolveToolName` that restores this function's * original debug-log wording so generation-path behaviour is unchanged. */ function repairToolName(toolCall, availableTools) { const called = toolCall.toolName; const resolution = resolveToolName(called, availableTools); if (!resolution) { logger.debug(`[ToolCallRepair] Could not repair tool name "${called}". Available: [${availableTools.join(", ")}]`); return null; } const label = resolution.strategy === "levenshtein" ? `levenshtein ${resolution.score.toFixed(2)}` : resolution.strategy; logger.debug(`[ToolCallRepair] Name repair (${label}): "${called}" → "${resolution.name}"`); return { ...toolCall, toolName: resolution.name }; } // ─── Tool Input Repair ───────────────────────────────────────────── /** * Attempt to repair wrong parameter names and types using the JSON schema. * Compares LLM-provided keys against schema properties dynamically. * * `toolCall.input` is a JSON string per LanguageModelV3ToolCall. */ function repairToolInput(toolCall, schema) { let args; try { args = JSON.parse(toolCall.input); } catch { return null; // input is not valid JSON — can't repair } if (!args || typeof args !== "object" || Array.isArray(args)) { return null; } const schemaProps = schema.properties; if (!schemaProps) { return null; } const expectedKeys = Object.keys(schemaProps); const inputObj = args; const inputKeys = Object.keys(inputObj); const repaired = Object.create(null); let didRepair = false; const dropUnknown = schema.additionalProperties === false; for (const inputKey of inputKeys) { // Already matches a schema property — keep as-is if (expectedKeys.includes(inputKey)) { repaired[inputKey] = inputObj[inputKey]; continue; } // Try to find a matching schema property const mapped = findMatchingKey(inputKey, expectedKeys); if (mapped) { // Don't overwrite an already-populated canonical key — but still mark as repaired // so the function returns the corrected object instead of null. if (Object.prototype.hasOwnProperty.call(repaired, mapped)) { didRepair = true; continue; } logger.debug(`[ToolCallRepair] Param repair: "${inputKey}" → "${mapped}" (tool: ${toolCall.toolName})`); repaired[mapped] = inputObj[inputKey]; didRepair = true; } else if (dropUnknown) { // Schema forbids extra properties — drop unmapped keys logger.debug(`[ToolCallRepair] Dropping unmapped key "${inputKey}" (additionalProperties: false, tool: ${toolCall.toolName})`); didRepair = true; } else { // Unknown key — pass through (schema allows additionalProperties) repaired[inputKey] = inputObj[inputKey]; } } // Type coercion based on schema types for (const key of Object.keys(repaired)) { const propSchema = schemaProps[key]; if (!propSchema) { continue; } const coerced = coerceType(repaired[key], propSchema); if (coerced !== repaired[key]) { logger.debug(`[ToolCallRepair] Type coercion on "${key}": ${typeof repaired[key]} → ${typeof coerced} (tool: ${toolCall.toolName})`); repaired[key] = coerced; didRepair = true; } } if (didRepair) { return { ...toolCall, input: JSON.stringify(repaired) }; } return null; } /** * Find a matching schema key for a mismatched input key. * Strategies: case-insensitive → Levenshtein (threshold ≤2 edits). */ function findMatchingKey(inputKey, schemaKeys) { const inputLower = inputKey.toLowerCase(); // Case-insensitive match const ciMatch = schemaKeys.find((k) => k.toLowerCase() === inputLower); if (ciMatch) { return ciMatch; } // Levenshtein — threshold ≤2 edits. Same over-limit guard as // resolveToolName above: skip rather than let levenshtein() truncate. let best = null; let bestDist = Infinity; if (inputLower.length <= MAX_COMPARABLE_LENGTH) { for (const k of schemaKeys) { const kLower = k.toLowerCase(); if (kLower.length > MAX_COMPARABLE_LENGTH) { continue; } const dist = levenshtein(inputLower, kLower); if (dist <= 2 && dist < bestDist) { bestDist = dist; best = k; } } } return best; } // ─── Type Coercion ───────────────────────────────────────────────── /** * Coerce a value to match the expected schema type. * Handles: string→number, JSON string→object, JSON string→array, value→[value]. * Exported for reuse by the MCP-layer parameter validator * (toolDiscoveryService), which coerces before rejecting so a recoverable * mismatch doesn't cost the agent loop a full model round-trip. */ export function coerceType(value, propSchema) { const expectedType = propSchema.type; if (!expectedType || value === null || value === undefined) { return value; } // String → Number (trim first, reject empty/whitespace, require finite result) if (expectedType === "number" && typeof value === "string") { const trimmed = value.trim(); if (trimmed !== "") { const num = Number(trimmed); if (isFinite(num)) { return num; } } } // String → Integer (strict: reject "12abc", "3.7", etc.) if (expectedType === "integer" && typeof value === "string") { const trimmed = value.trim(); if (/^[+-]?\d+$/.test(trimmed)) { const num = Number(trimmed); if (Number.isSafeInteger(num)) { return num; } } } // String → Boolean if (expectedType === "boolean" && typeof value === "string") { if (value.toLowerCase() === "true") { return true; } if (value.toLowerCase() === "false") { return false; } } // JSON string → Object if (expectedType === "object" && typeof value === "string") { try { const parsed = JSON.parse(value); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed; } } catch { // Not valid JSON — return as-is } } // JSON string → Array if (expectedType === "array" && typeof value === "string") { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed; } } catch { // Not valid JSON — return as-is } } // Single non-string value → Array (wrap). // Strings are excluded because they are more likely a JSON-encoded array // that failed to parse above, and wrapping "foo" into ["foo"] is rarely correct. if (expectedType === "array" && !Array.isArray(value) && typeof value !== "string") { return [value]; } return value; }