@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
594 lines (593 loc) • 27.6 kB
JavaScript
/**
* Tools Manager Module
*
* Handles all tool registration, discovery, and execution for AI providers.
* Extracted from BaseProvider to follow Single Responsibility Principle.
*
* Responsibilities:
* - Tool registration (direct, custom, MCP, external MCP)
* - Tool discovery and aggregation
* - Tool creation from definitions and schemas
* - Tool executor setup
* - Session context management for MCP tools
* - Event emission wrapping for tool execution
*
* @module core/modules/ToolsManager
*/
import { tool as createAISDKTool, jsonSchema } from "ai";
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 } from "../../context/toolOutputLimits.js";
/**
* ToolsManager class - Handles all tool management operations
*/
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.
*/
wrapExecuteWithTruncation(toolName, originalExecute) {
return async (params) => {
const result = await originalExecute(params);
return this.truncateToolResult(toolName, result);
};
}
/**
* 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;
}
// Handle string results directly
if (typeof result === "string") {
const { preview, truncated, originalSize } = generateToolOutputPreview(result);
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
if (typeof obj.content === "string") {
const { preview, truncated, originalSize } = generateToolOutputPreview(obj.content);
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);
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;
}
// For other objects, check if their JSON serialization is too large.
// Use UTF-8 byte length, not string length, to match the 50KB budget.
try {
const jsonStr = JSON.stringify(result);
if (Buffer.byteLength(jsonStr, "utf-8") > 51_200) {
const { preview, truncated, originalSize } = generateToolOutputPreview(jsonStr);
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`,
],
});
return tools;
});
}
/**
* Get direct tools (built-in agent tools)
*/
getDirectTools() {
return this.directTools;
}
/**
* 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) => {
const startTime = Date.now();
this.emitToolEvent("tool:start", toolName, { input: params });
try {
const result = await guardedExecute(params);
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;
finalSchema = jsonSchema(fixedSchema);
}
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) => {
const startTime = Date.now();
this.emitToolEvent("tool:start", tool.name, { input: params });
try {
const result = await guardedExecute(params);
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;
}
}
}