// v1.13.16: Levenshtein + suggestion + formatter for the unknown-tool error // returned to the model when an XML-extracted tool call references a name // that isn't in TOOLS_BY_NAME. The drift incident this targets: qwen3.6 // emitting from its Claude Code training residue // when BooCode's actual file-read tool is view_file. Hand-rolled distance // function — no new dep. export function levenshtein(a: string, b: string): number { if (a.length === 0) return b.length; if (b.length === 0) return a.length; const dp: number[][] = Array.from( { length: a.length + 1 }, () => new Array(b.length + 1).fill(0), ); for (let i = 0; i <= a.length; i++) dp[i]![0] = i; for (let j = 0; j <= b.length; j++) dp[0]![j] = j; for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; dp[i]![j] = Math.min( dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost, ); } } return dp[a.length]![b.length]!; } // Threshold per the v1.13.16 dispatch: distance <= 3 OR substring match // (either direction). Ties broken by smallest distance, then alphabetical. export function suggestToolName( name: string, available: readonly string[], ): string | null { const lower = name.toLowerCase(); let best: { name: string; dist: number } | null = null; for (const tool of available) { const tlower = tool.toLowerCase(); const dist = levenshtein(lower, tlower); const isSubstr = tlower.includes(lower) || lower.includes(tlower); if (dist > 3 && !isSubstr) continue; if ( best === null || dist < best.dist || (dist === best.dist && tool.localeCompare(best.name) < 0) ) { best = { name: tool, dist }; } } return best?.name ?? null; } export function formatUnknownToolError( name: string, available: readonly string[], ): string { const sorted = [...available].sort(); const suggestion = suggestToolName(name, sorted); const list = sorted.join(', '); const tail = suggestion ? ` Did you mean: ${suggestion}?` : ''; return `Tool '${name}' not found. Available tools: [${list}].${tail}`; }