/** * Tool-call name normalization for malformed LLM tool_use outputs. * * LLMs — especially smaller / faster models — frequently emit tool_use * blocks with names that don't exactly match the registered tool name: * * - Wrong delimiter: `outlook/operations` vs `outlook_operations` * - Function-prefix style: `functions.outlook_operations` * - Case drift: `Outlook_Operations` * - Counter suffix: `outlook_operations_1` * - Missing name, only id: `{name: "", id: "tool_outlook_operations_42"}` * * Before this normalization layer, any of the above caused the LangGraph * ToolNode to throw "Tool X not found" and the whole turn to fail. With * normalization, we resolve to the correct registered name case-insensitively * and transparently fix the tool_call in place. * * Resolution order: * 1. Exact match * 2. Normalized delimiter + exact * 3. Case-insensitive match * 4. Structured candidates (strip prefixes, take suffix segments) * 5. Infer from tool_call id (strip trailing digits, function/tool prefix) * * Any unresolvable tool_call is left as-is — the downstream ToolNode will * fail with its usual error and the auto-recovery path kicks in. */ function lowercaseOrEmpty(value: string): string { return typeof value === 'string' ? value.toLowerCase() : ''; } /** Collapse common delimiters (space, dot, slash, dash) to `_` and fold case. */ function normalizeDelimiters(name: string): string { return name .trim() .replace(/[\s./-]+/g, '_') .replace(/_{2,}/g, '_') .replace(/^_+|_+$/g, ''); } function resolveCaseInsensitive( rawName: string, allowed: Set ): string | null { if (allowed.size === 0) return null; const folded = lowercaseOrEmpty(rawName); let match: string | null = null; for (const name of allowed) { if (lowercaseOrEmpty(name) !== folded) continue; // Ambiguous → fail closed if (match && match !== name) return null; match = name; } return match; } function resolveExact(rawName: string, allowed: Set): string | null { if (allowed.size === 0) return null; if (allowed.has(rawName)) return rawName; const normalized = normalizeDelimiters(rawName); if (allowed.has(normalized)) return normalized; return ( resolveCaseInsensitive(rawName, allowed) ?? resolveCaseInsensitive(normalized, allowed) ); } function buildStructuredCandidates(rawName: string): string[] { const trimmed = rawName.trim(); if (!trimmed) return []; const seen = new Set(); const out: string[] = []; const add = (value: string): void => { const c = value.trim(); if (!c || seen.has(c)) return; seen.add(c); out.push(c); }; add(trimmed); add(normalizeDelimiters(trimmed)); // Also try dot-normalized, then split const dotForm = trimmed.replace(/\//g, '.'); add(dotForm); add(normalizeDelimiters(dotForm)); const segments = dotForm .split('.') .map((s) => s.trim()) .filter(Boolean); if (segments.length > 1) { for (let i = 1; i < segments.length; i++) { const suffix = segments.slice(i).join('.'); add(suffix); add(normalizeDelimiters(suffix)); } } return out; } function resolveStructured( rawName: string, allowed: Set ): string | null { if (allowed.size === 0) return null; const candidates = buildStructuredCandidates(rawName); for (const c of candidates) { if (allowed.has(c)) return c; } for (const c of candidates) { const ci = resolveCaseInsensitive(c, allowed); if (ci) return ci; } return null; } function inferFromToolCallId( rawId: string | undefined, allowed: Set ): string | null { if (!rawId || allowed.size === 0) return null; const id = rawId.trim(); if (!id) return null; const tokens = new Set(); const addTokens = (value: string): void => { const t = value.trim(); if (!t) return; tokens.add(t); tokens.add(t.replace(/[:._/-]\d+$/, '')); tokens.add(t.replace(/\d+$/, '')); const dotForm = t.replace(/\//g, '.'); tokens.add(dotForm); tokens.add(dotForm.replace(/[:._-]\d+$/, '')); tokens.add(dotForm.replace(/\d+$/, '')); for (const prefix of [/^functions?[._-]?/i, /^tools?[._-]?/i]) { const stripped = dotForm.replace(prefix, ''); if (stripped !== dotForm) { tokens.add(stripped); tokens.add(stripped.replace(/[:._-]\d+$/, '')); tokens.add(stripped.replace(/\d+$/, '')); } } }; const preColon = id.split(':')[0] ?? id; addTokens(id); addTokens(preColon); let singleMatch: string | null = null; for (const token of tokens) { const matched = resolveStructured(token, allowed); if (!matched) continue; // Ambiguous → fail closed if (singleMatch && singleMatch !== matched) return null; singleMatch = matched; } if (singleMatch) return singleMatch; // Substring fallback: ids frequently look like `call__` or // `toolu_01......`. Scan for any allowed name embedded in the // delimiter-normalized id (case-insensitive) and fail closed if more than // one distinct allowed name matches. const haystack = lowercaseOrEmpty(normalizeDelimiters(id)); let substrMatch: string | null = null; for (const allowedName of allowed) { const needle = lowercaseOrEmpty(allowedName); if (!needle) continue; if (!haystack.includes(needle)) continue; if (substrMatch && substrMatch !== allowedName) return null; substrMatch = allowedName; } return substrMatch; } /** * Produce the best allowed tool name for a raw LLM tool_use name. * Returns the original name unchanged if no resolution is possible — * the downstream executor will then fail with its normal error path. */ export function normalizeToolCallName( rawName: string, allowedToolNames: Set, rawToolCallId?: string ): string { const trimmed = rawName.trim() ?? ''; if (!trimmed) { return ( inferFromToolCallId(rawToolCallId, allowedToolNames) ?? rawName ?? '' ); } if (allowedToolNames.size === 0) return trimmed; const exact = resolveExact(trimmed, allowedToolNames); if (exact) return exact; const structured = resolveStructured(trimmed, allowedToolNames); if (structured) return structured; // Last-resort: try the raw id (if supplied). Substring matching lives // inside inferFromToolCallId — only safe to invoke when the caller // passed a real provider id, not when the raw name was used above. const inferred = inferFromToolCallId(rawToolCallId, allowedToolNames); if (inferred) return inferred; return trimmed; } /** * In-place normalization of all tool_calls on an AIMessage-like object. * * Applies `normalizeToolCallName` to each tool_call's `name` field using the * provided allowed-tool set (derived from the agent's toolMap keys). Also * rewrites the `name` field inside any content blocks of type `tool_use` so * that downstream providers see the corrected name. * * Returns `true` if any name was rewritten. */ export function normalizeMessageToolCalls( message: unknown, allowedToolNames: Set ): boolean { if (!message || typeof message !== 'object') return false; let changed = false; // LangChain AIMessage.tool_calls (normalized form) const msg = message as { tool_calls?: Array<{ name?: string; id?: string }>; content?: unknown; }; if (Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { if (!tc || typeof tc !== 'object') continue; const rawName = typeof tc.name === 'string' ? tc.name : ''; const rawId = typeof tc.id === 'string' ? tc.id : undefined; const normalized = normalizeToolCallName( rawName, allowedToolNames, rawId ); if (normalized && normalized !== rawName) { tc.name = normalized; changed = true; } } } // Anthropic-style content blocks with type: 'tool_use' if (Array.isArray(msg.content)) { for (const block of msg.content as Array<{ type?: unknown; name?: unknown; id?: unknown; }>) { if (!block || typeof block !== 'object') continue; if (block.type !== 'tool_use') continue; const rawName = typeof block.name === 'string' ? block.name : ''; const rawId = typeof block.id === 'string' ? block.id : undefined; const normalized = normalizeToolCallName( rawName, allowedToolNames, rawId ); if (normalized && normalized !== rawName) { block.name = normalized; changed = true; } } } return changed; }