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

1,055 lines • 50.2 kB
import { z } from "zod";
import { createToolEventPayload } from "../toolEvents.js";
import { tracers, ATTR, observe, toolSpanName, withSpan, } from "../../telemetry/index.js";
import { SpanStatusCode } from "@opentelemetry/api";
import { logger } from "../../utils/logger.js";
import { TOOL_EVENTS_WRAPPED } from "../../types/index.js";
import { getKeyCount } from "../../utils/transformationUtils.js";
import { extractIsErrorText } from "../../utils/toolResultStatus.js";
import { convertJsonSchemaToZod } from "../../utils/schemaConversion.js";
import { generateToolOutputPreview, DEFAULT_MAX_PREVIEW_BYTES, RETRIEVE_CONTEXT_TOOL_NAME, } from "../../context/toolOutputLimits.js";
/**
 * Own property stamped on a tool object whose `execute` already emits
 * `tool:start` / `tool:end`. It lives on the OBJECT, not the function: later
 * layers (the execution recorder, discovery) replace `execute` via
 * `{ ...tool, execute }`, which drops a function-identity check but carries a
 * symbol-keyed own property along — so one execution still yields one pair.
 */
function isEventWrappedTool(tool) {
    return (typeof tool === "object" &&
        tool !== null &&
        tool[TOOL_EVENTS_WRAPPED] === true);
}
function markEventWrapped(tool) {
    return Object.assign(tool, { [TOOL_EVENTS_WRAPPED]: true });
}
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;
    // Execute functions this manager already instrumented for tool events,
    // so re-wrapping is a no-op rather than a second start/end pair.
    eventWrappedExecutes = new WeakSet();
    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;
    }
    /**
     * Emit one tool event. A listener that throws must never reach the tool
     * loop: it would turn a successful execution into a failed one (the model
     * then retries a side effect that already happened) and, from inside the
     * wrapper's catch, produce a second `tool:end` for the same call. Listener
     * failures are logged and swallowed here instead.
     */
    emitToolEvent(eventName, toolName, payload) {
        if (!this.neurolink?.getEventEmitter) {
            return;
        }
        try {
            this.neurolink
                .getEventEmitter()
                .emit(eventName, createToolEventPayload(toolName, payload));
        }
        catch (listenerError) {
            logger.warn(`A ${eventName} listener threw for tool ${toolName}`, {
                error: listenerError instanceof Error
                    ? listenerError.message
                    : String(listenerError),
            });
        }
    }
    /**
     * Wrap an execute function so one `tool:start` and exactly one `tool:end`
     * (with the real duration) fire per invocation. Idempotent: a function this
     * manager already wrapped is returned as is, so a tool that re-enters
     * resolution (the stream → generate fallback re-resolves `options.tools`)
     * never emits twice.
     *
     * A result the tool RETURNED with `isError: true` (MCP failure, breaker
     * refusal, a thrown error already converted by `neurolink.executeTool`) is
     * a failure: `tool:end` carries `success: false` and the extracted text,
     * as the step-finish emitter this wrapper replaced did.
     */
    wrapExecuteWithEvents(toolName, execute) {
        if (this.eventWrappedExecutes.has(execute)) {
            return execute;
        }
        const wrapped = async (params, execOptions) => {
            // The loop's execute options carry the model's tool_call id, which is
            // what lets a listener pair concurrent same-name start/end events.
            const toolCallId = execOptions && typeof execOptions === "object"
                ? execOptions.toolCallId
                : undefined;
            const idField = typeof toolCallId === "string" && toolCallId.length > 0
                ? { toolCallId }
                : {};
            return withSpan({
                name: toolSpanName(toolName),
                tracer: tracers.mcp,
                attributes: { "tool.name": toolName, ...idField },
            }, async (span) => {
                observe(span, "tool", { input: params });
                const startTime = Date.now();
                this.emitToolEvent("tool:start", toolName, {
                    input: params,
                    ...idField,
                });
                let result;
                try {
                    result = await execute(params, execOptions);
                }
                catch (error) {
                    const errorMsg = error instanceof Error ? error.message : String(error);
                    this.emitToolEvent("tool:end", toolName, {
                        error: errorMsg,
                        success: false,
                        responseTime: Date.now() - startTime,
                        ...idField,
                    });
                    throw error;
                }
                const isErrorText = extractIsErrorText(result);
                observe(span, "tool", {
                    output: result,
                    ...(isErrorText !== undefined
                        ? { level: "ERROR", statusMessage: isErrorText }
                        : {}),
                });
                this.emitToolEvent("tool:end", toolName, {
                    result,
                    success: isErrorText === undefined,
                    ...(isErrorText !== undefined ? { error: isErrorText } : {}),
                    responseTime: Date.now() - startTime,
                    ...idField,
                });
                return result;
            });
        };
        this.eventWrappedExecutes.add(wrapped);
        return wrapped;
    }
    /**
     * Report a tool call the loop rejected BEFORE any execute ran — an unknown
     * tool name, arguments the schema rejected, a breaker skip. Nothing else
     * emits for such a call, so listeners (memory event capture, tool spans,
     * error-rate metrics) saw zero attempts while the result list held the
     * failures. One `tool:start` / `tool:end(success: false)` pair.
     */
    emitRejectedToolCall(toolName, error, toolCallId) {
        const idField = toolCallId ? { toolCallId } : {};
        this.emitToolEvent("tool:start", toolName, { ...idField });
        this.emitToolEvent("tool:end", toolName, {
            error,
            success: false,
            responseTime: 0,
            ...idField,
        });
    }
    /**
     * Give per-call tools (`options.tools`) the same `tool:start` / `tool:end`
     * emission that registered and MCP tools get from their executors. Tools
     * without an `execute` pass through untouched.
     */
    wrapExternalToolsWithEvents(tools) {
        const wrapped = {};
        for (const [toolName, tool] of Object.entries(tools)) {
            if (isEventWrappedTool(tool)) {
                wrapped[toolName] = tool;
                continue;
            }
            const execute = tool && typeof tool === "object" && "execute" in tool
                ? tool.execute
                : undefined;
            wrapped[toolName] =
                typeof execute === "function"
                    ? markEventWrapped({
                        ...tool,
                        execute: this.wrapExecuteWithEvents(toolName, execute),
                    })
                    : tool;
        }
        return wrapped;
    }
    /**
     * 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("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: "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] = markEventWrapped({
                    ...directTool,
                    execute: this.wrapExecuteWithEvents(toolName, guardedExecute),
                });
            }
            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({});
            }
            // Emits its own tool:start / tool:end through neurolink.executeTool, so
            // it is marked as instrumented and never wrapped a second time.
            return markEventWrapped(createAISDKTool({
                description: toolInfo.description || `Tool ${toolName}`,
                inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters)
                execute: async (params) => {
                    const customToolSpan = tracers.sdk.startSpan("tools.execute_custom", {
                        attributes: {
                            "tool.name": toolName,
                            "tool.type": "custom",
                        },
                    });
                    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);
            const loggedExecute = async (params, execOptions) => {
                try {
                    return await guardedExecute(params, execOptions);
                }
                catch (mcpError) {
                    logger.error(`External MCP tool failed: ${tool.name}`, {
                        serverId: tool.serverId,
                        error: mcpError instanceof Error ? mcpError.message : String(mcpError),
                    });
                    throw mcpError;
                }
            };
            return markEventWrapped(createAISDKTool({
                description: tool.description || `External MCP tool ${tool.name}`,
                inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters)
                execute: this.wrapExecuteWithEvents(tool.name, loggedExecute),
            }));
        }
        catch (toolCreationError) {
            logger.error(`Failed to create external MCP tool: ${tool.name}`, toolCreationError);
            return null;
        }
    }
}