@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
JavaScript
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;
}
}
}