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

942 lines (941 loc) • 45.6 kB
import { z } from "zod"; import { createToolEventPayload } from "../toolEvents.js"; import { tracers, ATTR, withSpan } from "../../telemetry/index.js"; import { SpanStatusCode } from "@opentelemetry/api"; import { logger } from "../../utils/logger.js"; import { getKeyCount } from "../../utils/transformationUtils.js"; import { convertJsonSchemaToZod } from "../../utils/schemaConversion.js"; import { generateToolOutputPreview, DEFAULT_MAX_PREVIEW_BYTES, RETRIEVE_CONTEXT_TOOL_NAME, } from "../../context/toolOutputLimits.js"; import { tool as createAISDKTool, jsonSchema } from "../../utils/tool.js"; /** Abort-shaped error so provider loops route it to their cancellation path. */ function makeToolAbortError() { const e = new Error("Tool execution aborted"); e.name = "AbortError"; return e; } /** * Compiled-validator cache, keyed by the ORIGINAL MCP tool inputSchema * object. createExternalMCPTool runs once per external tool on EVERY * getAllTools() (i.e. every generation call), and the schema object identity * is stable for the lifetime of a server connection — without the cache the * same JSON Schema is recompiled on every generate() (hot-path CPU for * 50+-tool deployments). Rediscovery produces new schema objects, so stale * entries fall out via WeakMap semantics. `undefined` values (schemas that * failed to compile) are cached too — hence has()/get() rather than a * get()-only check. */ const mcpValidatorCache = new WeakMap(); /** * Build an argument validator for an external MCP tool's JSON Schema, in the * shape the AI SDK's `jsonSchema()` wrapper expects. Returns undefined when * the schema can't be compiled (exotic dialect) — the tool then keeps the * previous declarative-only behaviour instead of failing registration. * * The error message is written for the MODEL (it is fed back verbatim as the * tool-error text): it names the offending fields and restates the contract * (required properties + types) so the retry can succeed on the first attempt. */ export async function buildMCPSchemaValidator(toolName, schema) { try { const { Validator } = await import("@cfworker/json-schema"); // draft-07: the lingua franca of MCP server inputSchemas. shortCircuit // false so the error lists every violation, not just the first. const validator = new Validator(schema, "7", false); const required = Array.isArray(schema.required) ? schema.required : []; const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {}; const contract = Object.entries(properties) .map(([key, prop]) => `${key}${required.includes(key) ? "" : "?"}: ${prop?.type ?? "any"}`) .join(", "); return (value) => { try { const result = validator.validate(value); if (result.valid) { return { success: true, value }; } // Root-level entries ("#") are mostly generic "instance does not // match schema" noise — EXCEPT missing-required errors, which carry // the offending property name and must reach the model. const details = result.errors .filter((e) => e.instanceLocation !== "#" || /required property/i.test(e.error)) .slice(0, 3) .map((e) => { const loc = e.instanceLocation.replace(/^#\/?/, ""); return loc ? `${loc}: ${e.error}` : e.error; }) .join("; "); return { success: false, error: new Error(`Invalid arguments for tool '${toolName}'${details ? ` — ${details}` : ""}. ` + `Expected: { ${contract} } (send every non-optional property with the exact name and JSON type).`), }; } catch (validationError) { // Validator crashed on this instance — treat as valid rather than // block the call; the MCP-layer validator still backstops execution. logger.debug(`[ToolsManager] Schema validator failed for '${toolName}', passing through`, { error: validationError instanceof Error ? validationError.message : String(validationError), }); return { success: true, value }; } }; } catch (compileError) { logger.debug(`[ToolsManager] Could not compile schema validator for '${toolName}' — arguments will not be pre-validated`, { error: compileError instanceof Error ? compileError.message : String(compileError), }); return undefined; } } /** * Race a tool-execution promise against an AbortSignal so the calling loop * observes a deadline/caller abort IMMEDIATELY instead of waiting for the * tool to finish or its execution timeout to expire. The underlying call is * not cancelled (bounded ghost execution — the same tradeoff as the tool * timeout); real transport-level cancellation (executeExternalMCPTool → MCP * client RequestOptions.signal) is tracked as a follow-up. */ function raceWithAbortSignal(promise, signal) { if (signal.aborted) { // Swallow the abandoned settlement so it can't become an unhandled // rejection later. promise.catch(() => { // Swallow the abandoned settlement — it must never surface as an // unhandled rejection after the race has already been decided. }); return Promise.reject(makeToolAbortError()); } return new Promise((resolve, reject) => { const onAbort = () => { promise.catch(() => { // Swallow the abandoned settlement — it must never surface as an // unhandled rejection after the race has already been decided. }); reject(makeToolAbortError()); }; signal.addEventListener("abort", onAbort, { once: true }); promise.then((value) => { signal.removeEventListener("abort", onAbort); resolve(value); }, (error) => { signal.removeEventListener("abort", onAbort); reject(error); }); }); } /** * ToolsManager class - Handles all tool management operations */ /** Upper bound on re-measure-and-tighten rounds in truncateMcpContentEnvelope. */ const MAX_ENVELOPE_TIGHTENING_PASSES = 6; function serializedByteLength(value) { try { return Buffer.byteLength(JSON.stringify(value), "utf-8"); } catch { return undefined; } } function isTextContentItem(item) { return (item !== null && typeof item === "object" && item.type === "text" && typeof item.text === "string"); } /** * Split `total` bytes across items of the given sizes, water-filling: * smallest first, each item takes the lesser of its own size and an equal * share of what is left, so items that already fit keep everything and the * remainder flows to the larger ones. */ function waterFillTextBudget(sizes, total) { const allocations = new Array(sizes.length).fill(0); const order = sizes .map((_, index) => index) .sort((a, b) => sizes[a] - sizes[b]); let remaining = Math.max(0, total); let left = order.length; for (const index of order) { const share = Math.floor(remaining / left); const give = Math.min(sizes[index], share); allocations[index] = give; remaining -= give; left--; } return allocations; } export class ToolsManager { providerName; directTools; neurolink; utilities; // Tool storage mcpTools; customTools; toolExecutor; // Session context sessionId; userId; constructor(providerName, directTools, neurolink, utilities) { this.providerName = providerName; this.directTools = directTools; this.neurolink = neurolink; this.utilities = utilities; this.mcpTools = {}; } /** * BZ-666: Wrap tool execute with output truncation to prevent * context overflow when large results flow into the AI SDK accumulator. * * Passes the AI-SDK second argument (execution options: abortSignal, * toolCallId, messages) through to the inner execute — the native loops * provide an abortSignal there, and dropping it at this wrapper made * every tool uncancellable (deadline overshoot / ghost executions). */ wrapExecuteWithTruncation(toolName, originalExecute) { return async (params, execOptions) => { const signal = execOptions ?.abortSignal; const inner = originalExecute(params, execOptions); // Inner executes that ignore the signal (external MCP / custom tools — // the signal isn't plumbed to their transports yet) still return // promptly on abort via the race; the loop's isAbortError handling // treats the rejection as a cancellation, not a tool failure. const result = signal && typeof signal.addEventListener === "function" ? await raceWithAbortSignal(inner, signal) : await inner; return this.truncateToolResult(toolName, result); }; } /** * Byte budget for BZ-666 truncation. Reads the instance-level * `tools.outputTruncationMaxBytes` config (see `ToolConfig` / * `NeuroLink.getToolsConfig()`) so hosts can raise or lower the ceiling * without a new env var or constructor param; falls back to the original * hard-coded 51,200 bytes (== DEFAULT_MAX_PREVIEW_BYTES) so existing * consumers see no behavior change. */ resolveTruncationMaxBytes() { const configured = this.neurolink?.getToolsConfig?.()?.outputTruncationMaxBytes; return typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MAX_PREVIEW_BYTES; } /** * The default omission notice names `retrieve_context` as the on-demand * read-back tool. That is only true when this NeuroLink instance actually * registered it (Redis or an artifact store must exist — see * `retrieveContextRegistered` in neurolink.ts). Otherwise hand * `generateToolOutputPreview` a notice that doesn't mention it. */ resolveTruncationNotice() { const hasRetrieveContext = this.neurolink?.getCustomTools?.()?.has(RETRIEVE_CONTEXT_TOOL_NAME) ?? false; if (hasRetrieveContext) { return undefined; // defer to generateToolOutputPreview's own default } return (omittedBytes) => `\n\n[... ${omittedBytes} bytes omitted ...]\n\n`; } /** * Truncate an MCP `CallToolResult`-shaped envelope (`{content:[...], * isError?}`) — at the top level, or nested under `data` the way * NeuroLink's own `executeExternalMCPTool` / `{success, data}` wrapper * shapes it — WITHOUT collapsing it to the generic `_truncated` sentinel, * while still honouring `maxBytes` for the envelope AS A WHOLE. * * Only TEXT content items are rewritten (byte-accurate head/tail preview, * same mechanism as a plain string result). `isError` and any non-text * items (images, resources, embedded resources, …) pass through * untouched, and every other top-level field on the result is preserved. * * The budget is enforced on the serialized envelope, not per item: the * bytes the envelope occupies with every text item emptied (structure, * non-text items, other fields) come off the top, and what remains is * water-filled across the text items — items that already fit keep their * text and donate the rest of their share to the larger ones. JSON * escaping and the per-item notice add bytes the raw allocation cannot * see, so the result is re-measured and the text budget tightened until * the whole envelope fits. * * Returns `undefined` when `result` doesn't have this shape at all, AND * when the shape cannot be kept within `maxBytes` — no text items, the * non-text payload alone exceeds the budget, or tightening did not * converge — so the caller's generic sentinel path bounds it instead. * Preserving the shape never buys the right to exceed the ceiling. */ truncateMcpContentEnvelope(toolName, obj, maxBytes) { const atTopLevel = Array.isArray(obj.content); const dataObj = !atTopLevel && obj.data && typeof obj.data === "object" ? obj.data : undefined; const nestedUnderData = !atTopLevel && Array.isArray(dataObj?.content); if (!atTopLevel && !nestedUnderData) { return undefined; } const envelope = atTopLevel ? obj : dataObj; const serializedSize = serializedByteLength(obj); if (serializedSize === undefined) { return obj; // unserializable — leave the envelope exactly as-is } if (serializedSize <= maxBytes) { return obj; } const content = envelope.content; const textIndexes = []; content.forEach((item, index) => { if (isTextContentItem(item)) { textIndexes.push(index); } }); if (textIndexes.length === 0) { return undefined; // nothing trimmable — let the sentinel bound it } const rebuild = (replacements) => { const newContent = content.map((item, index) => { const text = replacements.get(index); return text === undefined ? item : { ...item, text }; }); const newEnvelope = { ...envelope, content: newContent }; return atTopLevel ? newEnvelope : { ...obj, data: newEnvelope }; }; // Bytes the envelope costs with every text item emptied. If that alone // is over budget, no amount of text trimming honours the ceiling while // keeping the shape. const fixedBytes = serializedByteLength(rebuild(new Map(textIndexes.map((index) => [index, ""])))); if (fixedBytes === undefined || fixedBytes >= maxBytes) { return undefined; } const notice = this.resolveTruncationNotice(); const sizes = textIndexes.map((index) => Buffer.byteLength(content[index].text, "utf-8")); let textBudget = maxBytes - fixedBytes; for (let pass = 0; pass < MAX_ENVELOPE_TIGHTENING_PASSES; pass++) { const allocations = waterFillTextBudget(sizes, textBudget); const replacements = new Map(); // Record the sizes here, log them only if this pass is the one that // survives. A rejected pass never happened as far as the caller is // concerned, and up to MAX_ENVELOPE_TIGHTENING_PASSES of them can be // rejected on a single call — building their template strings, and the // byteLength each one interpolates, is work paid whether or not debug // logging is even enabled. const truncatedItems = []; textIndexes.forEach((index, k) => { const item = content[index]; const { preview, truncated, originalSize } = generateToolOutputPreview(item.text, { maxBytes: allocations[k], notice }); if (truncated) { truncatedItems.push({ index, originalSize }); replacements.set(index, preview); } }); const candidate = rebuild(replacements); const candidateSize = serializedByteLength(candidate); if (candidateSize !== undefined && candidateSize <= maxBytes) { if (logger.shouldLog("debug")) { for (const { index, originalSize } of truncatedItems) { logger.debug(`[ToolsManager] Truncated '${toolName}' MCP content text item ${index}: ${originalSize} bytes → ${Buffer.byteLength(replacements.get(index) ?? "", "utf-8")} bytes (envelope budget ${maxBytes})`); } } return candidate; } if (candidateSize === undefined || replacements.size === 0) { break; } // Over by the escaping/notice overhead the allocation could not see: // take that overage (plus a margin) back out of the text budget. textBudget -= candidateSize - maxBytes + Math.ceil(maxBytes * 0.02) + 1; if (textBudget <= 0) { break; } } return undefined; // could not fit while preserving the shape — sentinel } /** * BZ-666: Apply generateToolOutputPreview to tool results to prevent * context overflow when large results flow into the AI SDK accumulator. */ truncateToolResult(toolName, result) { if (result === null || result === undefined) { return result; } const maxBytes = this.resolveTruncationMaxBytes(); // Handle string results directly if (typeof result === "string") { const { preview, truncated, originalSize } = generateToolOutputPreview(result, { maxBytes, notice: this.resolveTruncationNotice() }); if (truncated) { logger.debug(`[ToolsManager] Truncated '${toolName}' string output: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`); } return truncated ? preview : result; } // Handle object results (e.g. readFile returns { content, ... }) if (typeof result === "object") { const obj = result; let nextObj = null; // Truncate "content" if present and oversized (string form only — // an ARRAY "content" is an MCP envelope, handled below) if (typeof obj.content === "string") { const { preview, truncated, originalSize } = generateToolOutputPreview(obj.content, { maxBytes, notice: this.resolveTruncationNotice() }); if (truncated) { logger.debug(`[ToolsManager] Truncated '${toolName}' content field: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`); nextObj = { ...(nextObj ?? obj), content: preview }; } } // Truncate "data" if present and oversized — both fields can coexist if (typeof obj.data === "string") { const { preview, truncated, originalSize } = generateToolOutputPreview(obj.data, { maxBytes, notice: this.resolveTruncationNotice() }); if (truncated) { logger.debug(`[ToolsManager] Truncated '${toolName}' data field: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`); nextObj = { ...(nextObj ?? obj), data: preview }; } } if (nextObj) { return nextObj; } // MCP CallToolResult envelope — { content: [{type,text}, ...], isError? } // — either at the top level or nested under `data` (NeuroLink's own // executeExternalMCPTool / {success, data} wrapper shape). Preserve the // envelope and truncate only the oversized text items instead of // collapsing the whole thing to the generic sentinel below. const envelopeResult = this.truncateMcpContentEnvelope(toolName, obj, maxBytes); if (envelopeResult !== undefined) { return envelopeResult; } // For other objects, check if their JSON serialization is too large. // Use UTF-8 byte length, not string length, to match the byte budget. try { const jsonStr = JSON.stringify(result); if (Buffer.byteLength(jsonStr, "utf-8") > maxBytes) { const { preview, truncated, originalSize } = generateToolOutputPreview(jsonStr, { maxBytes, notice: this.resolveTruncationNotice(), }); if (truncated) { logger.debug(`[ToolsManager] Truncated '${toolName}' JSON output: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`); // Preserve object shape so callers reading structured fields don't // get a type surprise. Attach the preview under a sentinel field. return { _truncated: true, _originalSize: originalSize, _preview: preview, }; } } } catch { // JSON serialization failed — return as-is } } return result; } /** * Set session context for MCP tools */ setSessionContext(sessionId, userId) { this.sessionId = sessionId; this.userId = userId; } emitToolEvent(eventName, toolName, payload) { if (this.neurolink?.getEventEmitter) { this.neurolink .getEventEmitter() .emit(eventName, createToolEventPayload(toolName, payload)); } } /** * Set up tool executor for a provider to enable actual tool execution * @param sdk - The NeuroLinkSDK instance for tool execution * @param functionTag - Function name for logging */ setupToolExecutor(sdk, functionTag) { const span = tracers.sdk.startSpan("neurolink.tools.register", { attributes: { [ATTR.NL_PROVIDER]: this.providerName, "tools.custom_count": sdk.customTools.size, }, }); try { // Store custom tools for use in getAllTools() this.customTools = sdk.customTools; this.toolExecutor = sdk.executeTool.bind(sdk); logger.debug(`[${functionTag}] Setting up tool executor for provider`, { providerName: this.providerName, availableCustomTools: sdk.customTools.size, customToolsStored: !!this.customTools, toolExecutorStored: !!this.toolExecutor, }); // Note: Tool execution will be handled through getAllTools() -> AI SDK tools // The custom tools are converted to AI SDK format in getAllTools() method span.setStatus({ code: SpanStatusCode.OK }); } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); if (error instanceof Error) { span.recordException(error); } throw error; } finally { span.end(); } } /** * Get all available tools - direct tools are ALWAYS available * MCP tools are added when available (without blocking) */ async getAllTools() { return withSpan({ name: "neurolink.tools.getAll", tracer: tracers.sdk, attributes: { [ATTR.NL_PROVIDER]: this.providerName, }, }, async (span) => { // Start with wrapped direct tools that emit events const tools = {}; // Wrap direct tools with event emission await this.processDirectTools(tools); const directCount = Object.keys(tools).length; span.setAttribute("tools.direct_count", directCount); logger.debug(`[ToolsManager] getAllTools called for ${this.providerName}`, { directToolsCount: getKeyCount(this.directTools), }); // Process all tool types using dedicated helper methods await this.processCustomTools(tools); const customCount = Object.keys(tools).length - directCount; span.setAttribute("tools.custom_count", customCount); await this.processExternalMCPTools(tools); const externalCount = Object.keys(tools).length - directCount - customCount; span.setAttribute("tools.external_mcp_count", externalCount); await this.processMCPTools(tools); const totalCount = Object.keys(tools).length; span.setAttribute(ATTR.NL_TOOL_COUNT, totalCount); // Record tool names for debugging (truncated) const toolNames = Object.keys(tools); span.setAttribute("tools.names", toolNames.slice(0, 20).join(",") + (toolNames.length > 20 ? `...+${toolNames.length - 20}` : "")); // Log a compact summary instead of full tool list logger.debug(`[ToolsManager] getAllTools complete: ${toolNames.length} tools available`, { provider: this.providerName, toolCount: toolNames.length, toolNames: toolNames.length <= 10 ? toolNames : [ ...toolNames.slice(0, 10), `... and ${toolNames.length - 10} more`, ], }); // NOTE: insertion order here is phase order (direct → custom → // external MCP), which the signature-dedup pass depends on (keep-first // must prefer built-in implementations). Deterministic name-sorting // for prompt-cache stability happens AFTER filtering+dedup, in // BaseProvider.applyToolFiltering. return tools; }); } /** * Get direct tools (built-in agent tools) */ getDirectTools() { return this.directTools; } /** * Replace the direct tools for this provider instance — used to bind the * built-in file tools to one request's root policy. Providers are created * per request, so this never leaks a boundary into another request. */ setDirectTools(tools) { this.directTools = tools; } /** * Get MCP tools */ getMCPTools() { return this.mcpTools; } /** * Get custom tools */ getCustomTools() { return this.customTools; } /** * Process direct tools with event emission wrapping */ async processDirectTools(tools) { if (!this.directTools || Object.keys(this.directTools).length === 0) { return; } logger.debug(`[ToolsManager] Loading ${Object.keys(this.directTools).length} direct tools`); for (const [toolName, directTool] of Object.entries(this.directTools)) { // Wrap the direct tool's execute function with event emission if (directTool && typeof directTool === "object" && "execute" in directTool) { const originalExecute = directTool.execute; // Create a new tool with wrapped execute function (BZ-666/BZ-664 guards applied) const guardedExecute = this.wrapExecuteWithTruncation(toolName, originalExecute); tools[toolName] = { ...directTool, execute: async (params, execOptions) => { const startTime = Date.now(); this.emitToolEvent("tool:start", toolName, { input: params }); try { const result = await guardedExecute(params, execOptions); this.emitToolEvent("tool:end", toolName, { result, success: true, responseTime: Date.now() - startTime, }); return result; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.emitToolEvent("tool:end", toolName, { error: errorMsg, success: false, responseTime: Date.now() - startTime, }); throw error; } }, }; } else { // Fallback: include tool as-is if it doesn't have execute function tools[toolName] = directTool; } } // Direct tools processing complete — count already logged at start } /** * Process custom tools from setupToolExecutor */ async processCustomTools(tools) { if (!this.customTools || this.customTools.size === 0) { return; } logger.debug(`[ToolsManager] Loading ${this.customTools.size} custom tools from setupToolExecutor`); for (const [toolName, toolDef] of this.customTools.entries()) { // Validate tool definition has required execute function const toolInfo = toolDef || {}; if (toolInfo && typeof toolInfo.execute === "function") { const tool = await this.createCustomToolFromDefinition(toolName, toolInfo); if (tool && !tools[toolName]) { // BZ-666/BZ-664: Wrap custom tool execute with guards const origExec = tool.execute; if (origExec) { const guarded = this.wrapExecuteWithTruncation(toolName, origExec); tool.execute = guarded; } tools[toolName] = tool; } } } // Custom tools processing complete — count already logged at start } /** * Process MCP tools integration */ async processMCPTools(tools) { // MCP tools loading simplified - removed functionCalling dependency if (!this.mcpTools) { // Set empty tools object - MCP tools are handled at a higher level this.mcpTools = {}; } // Add MCP tools if available, but don't overwrite existing direct tools // Direct tools (Zod-based) take precedence over MCP tools (JSON Schema) if (this.mcpTools) { for (const [name, tool] of Object.entries(this.mcpTools)) { if (!tools[name]) { tools[name] = tool; } } } } /** * Process external MCP tools */ async processExternalMCPTools(tools) { if (!this.neurolink || typeof this.neurolink.getExternalMCPTools !== "function") { return; } try { const externalTools = await this.neurolink.getExternalMCPTools(); let addedCount = 0; for (const tool of externalTools) { const mcpTool = await this.createExternalMCPTool(tool); if (mcpTool && !tools[tool.name]) { tools[tool.name] = mcpTool; addedCount++; } } logger.debug(`[ToolsManager] External MCP tools loaded`, { found: externalTools.length, added: addedCount, }); } catch (error) { logger.error(`[ToolsManager] Failed to load external MCP tools for ${this.providerName}:`, error); // Not an error - external tools are optional } } /** * Create a custom tool from tool definition */ async createCustomToolFromDefinition(toolName, toolInfo) { try { let finalSchema; let originalInputSchema; // Prioritize parameters (Zod), then inputSchema (Zod or JSON Schema) if (toolInfo.parameters && this.utilities?.isZodSchema?.(toolInfo.parameters)) { finalSchema = toolInfo.parameters; } else if (toolInfo.inputSchema && this.utilities?.isZodSchema?.(toolInfo.inputSchema)) { finalSchema = toolInfo.inputSchema; } else if (toolInfo.inputSchema && typeof toolInfo.inputSchema === "object") { // Use original JSON Schema with jsonSchema() wrapper - NO CONVERSION! originalInputSchema = toolInfo.inputSchema; finalSchema = jsonSchema(originalInputSchema); } else if (toolInfo.parameters && typeof toolInfo.parameters === "object") { finalSchema = convertJsonSchemaToZod(toolInfo.parameters); } else { finalSchema = z.object({}); } return createAISDKTool({ description: toolInfo.description || `Tool ${toolName}`, inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters) execute: async (params) => { const customToolSpan = tracers.sdk.startSpan("neurolink.tools.execute_custom", { attributes: { "tool.name": toolName, "tool.type": "custom", // Curator P1-3: pure wrapper — duplicates the AI SDK's // ai.toolCall observation in Langfuse. Keep the OTel span // for internal metrics; filter from Langfuse export. "langfuse.internal": true, }, }); const startTime = Date.now(); let executionId; try { // Route through NeuroLink.executeTool() when available for MCP enhancement support // (cache, middleware, annotations, circuit breaker, routing) if (this.toolExecutor) { // Per-tool timeout and retries flow through the customTools map // (set at registration via ToolRegistrationOptions). // The execute wrapper in registerTool already enforces timeouts, // but we also forward them to toolExecutor for MCP-level handling. const toolTimeoutMs = toolInfo.timeoutMs; const toolMaxRetries = toolInfo.maxRetries; const hasRegistrationOptions = toolTimeoutMs !== undefined || toolMaxRetries !== undefined; const result = await this.toolExecutor(toolName, params, hasRegistrationOptions ? { ...(toolTimeoutMs !== undefined && { timeout: toolTimeoutMs, }), ...(toolMaxRetries !== undefined && { maxRetries: toolMaxRetries, }), } : undefined); const convertedResult = this.utilities?.convertToolResult ? await this.utilities.convertToolResult(result) : result; const endTime = Date.now(); customToolSpan.setAttribute("tool.duration_ms", endTime - startTime); let errorResult = undefined; if (convertedResult && typeof convertedResult === "object" && "isError" in convertedResult && convertedResult.isError) { try { errorResult = JSON.stringify(convertedResult); } catch (error) { logger.error(`Failed to serialize error result for ${toolName}`, error); } } customToolSpan.setAttribute("tool.result.status", errorResult ? "error" : "success"); if (errorResult) { customToolSpan.setStatus({ code: SpanStatusCode.ERROR, message: `Tool ${toolName} returned isError: true`, }); } else { customToolSpan.setStatus({ code: SpanStatusCode.OK }); } return convertedResult; } // Fallback: direct execution (standalone usage without NeuroLink SDK) if (this.neurolink?.emitToolStart) { executionId = this.neurolink.emitToolStart(toolName, params, startTime); } const result = await toolInfo.execute(params); const convertedResult = this.utilities?.convertToolResult ? await this.utilities.convertToolResult(result) : result; const endTime = Date.now(); let errorResult = undefined; if (convertedResult && typeof convertedResult === "object" && "isError" in convertedResult && convertedResult.isError) { try { errorResult = JSON.stringify(convertedResult); } catch (error) { logger.error(`Failed to serialize error result for ${toolName}`, error); } } // Emit tool end event (success or handled error) if (this.neurolink?.emitToolEnd) { this.neurolink.emitToolEnd(toolName, convertedResult, errorResult, startTime, endTime, executionId); } customToolSpan.setAttribute("tool.duration_ms", endTime - startTime); customToolSpan.setAttribute("tool.result.status", errorResult ? "error" : "success"); if (errorResult) { customToolSpan.setStatus({ code: SpanStatusCode.ERROR, message: `Tool ${toolName} returned isError: true`, }); } else { customToolSpan.setStatus({ code: SpanStatusCode.OK }); } return convertedResult; } catch (error) { const endTime = Date.now(); const errorMsg = error instanceof Error ? error.message : String(error); // Emit tool end event (error) — only for fallback path // When toolExecutor is used, executeTool() handles event emission if (!this.toolExecutor && this.neurolink?.emitToolEnd) { this.neurolink.emitToolEnd(toolName, undefined, // no result errorMsg, startTime, endTime, executionId); logger.debug(`Custom tool error: ${toolName} (${endTime - startTime}ms)`, { error: errorMsg }); } customToolSpan.setAttribute("tool.duration_ms", endTime - startTime); customToolSpan.setAttribute("tool.result.status", "error"); customToolSpan.recordException(error instanceof Error ? error : new Error(errorMsg)); customToolSpan.setStatus({ code: SpanStatusCode.ERROR, message: errorMsg, }); throw error; } finally { customToolSpan.end(); } }, }); } catch (toolCreationError) { logger.error(`Failed to create tool: ${toolName}`, toolCreationError); return null; } } /** * Create an external MCP tool */ async createExternalMCPTool(tool) { try { // Use original JSON Schema from MCP tool if available, otherwise use permissive schema let finalSchema; if (tool.inputSchema && typeof tool.inputSchema === "object") { // Clone and fix the schema for OpenAI strict mode compatibility const originalSchema = tool.inputSchema; const fixedSchema = this.utilities?.fixSchemaForOpenAIStrictMode ? this.utilities.fixSchemaForOpenAIStrictMode(originalSchema) : originalSchema; // A jsonSchema() wrapper WITHOUT a validate function is declarative // only — the AI SDK passes any parsed arguments straight through // (safeValidateTypes short-circuits on `validate == null`). That let // malformed calls (missing required params, "123" for a number) // reach execution, where the MCP-layer validator rejected them at // the cost of a full model round-trip. Attach a real validator so // invalid calls fail at parse time, where experimental_repairToolCall // can still fix them silently. Compiled once per schema object — // see mcpValidatorCache. let validate; if (mcpValidatorCache.has(originalSchema)) { validate = mcpValidatorCache.get(originalSchema); } else { validate = await buildMCPSchemaValidator(tool.name, fixedSchema); mcpValidatorCache.set(originalSchema, validate); } finalSchema = jsonSchema(fixedSchema, validate ? { validate } : {}); } else { finalSchema = this.utilities?.createPermissiveZodSchema ? this.utilities.createPermissiveZodSchema() : z.object({}); } // BZ-666/BZ-664: Wrap the raw MCP execute with guards before event wrapping const rawExecute = async (params) => { if (this.neurolink && typeof this.neurolink.executeExternalMCPTool === "function") { return this.neurolink.executeExternalMCPTool(tool.serverId || "unknown", tool.name, params); } throw new Error(`Cannot execute external MCP tool: NeuroLink executeExternalMCPTool not available`); }; const guardedExecute = this.wrapExecuteWithTruncation(tool.name, rawExecute); return createAISDKTool({ description: tool.description || `External MCP tool ${tool.name}`, inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters) execute: async (params, execOptions) => { const startTime = Date.now(); this.emitToolEvent("tool:start", tool.name, { input: params }); try { const result = await guardedExecute(params, execOptions); this.emitToolEvent("tool:end", tool.name, { result, success: true, responseTime: Date.now() - startTime, }); return result; } catch (mcpError) { const errorMsg = mcpError instanceof Error ? mcpError.message : String(mcpError); this.emitToolEvent("tool:end", tool.name, { error: errorMsg, success: false, responseTime: Date.now() - startTime, }); logger.error(`External MCP tool failed: ${tool.name}`, { serverId: tool.serverId, error: errorMsg, }); throw mcpError; } }, }); } catch (toolCreationError) { logger.error(`Failed to create external MCP tool: ${tool.name}`, toolCreationError); return null; } } }