/**
 * On-demand tool discovery (`tools.discovery: true`).
 *
 * Instead of sending every external MCP tool's full schema on every request,
 * the model receives the hot set (built-in tools, per-call tools, explicitly
 * requested tools, and previously discovered tools) plus ONE `search_tools`
 * meta-tool whose description embeds a compact name+summary catalog of the
 * deferred tools. Calling `search_tools` loads matching tools:
 *  - immediately into the live tool record (providers that re-read the tools
 *    record between agent-loop steps — the AI SDK path — can call them on the
 *    very next step), and
 *  - into the session pin set (all providers include them in full on every
 *    subsequent call of the session).
 *
 * Evidence for this design: catalogs past ~30-50 tools measurably degrade
 * tool-selection accuracy, and deferral+search both cuts definition tokens by
 * ~85% and RAISES selection accuracy (Anthropic MCP-eval 49%→74% on Opus 4).
 * The catalog construction (byte-stable, name-sorted, char-budgeted with a
 * degradation ladder) mirrors the proven skills-index pattern in
 * src/lib/skills/skillTools.ts.
 */
import { z } from "zod";
import { tool as createAISDKTool } from "../utils/tool.js";
import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
import { logger } from "../utils/logger.js";
/**
 * Above this many tools, a WARN suggests enabling `tools.discovery` when it
 * is off. Chosen from the measured degradation range (30-50 tools).
 */
export const LARGE_CATALOG_WARN_THRESHOLD = 40;
/** Char budget for the catalog embedded in the search_tools description. */
const CATALOG_CHAR_BUDGET = 15_000;
/** Max tools loaded per search_tools call. */
const MAX_SEARCH_RESULTS = 5;
const SUMMARY_MAX_CHARS = 100;
/**
 * Marker property stamped on the generated search_tools meta-tool so
 * re-entrant resolution passes (e.g. the stream → generate fallback, whose
 * options.tools already contain a partitioned record) can distinguish OUR
 * stale meta-tool from a real user tool that happens to share the name.
 */
const DISCOVERY_META_TOOL_MARKER = "__nlDiscoveryMetaTool";
/** True when the value is a search_tools meta-tool generated by this module. */
export function isDiscoveryMetaTool(tool) {
    return (!!tool &&
        typeof tool === "object" &&
        tool[DISCOVERY_META_TOOL_MARKER] === true);
}
let warnedSearchToolsCollision = false;
function warnSearchToolsCollisionOnce() {
    if (warnedSearchToolsCollision) {
        return;
    }
    warnedSearchToolsCollision = true;
    logger.warn("[ToolDiscovery] A registered tool is already named 'search_tools' — on-demand discovery is skipped to avoid shadowing it. Rename that tool to use tools.discovery.");
}
/** First sentence of a description, capped for the catalog line. */
function summarize(description) {
    const firstSentence = description.split(/(?<=[.!?])\s/)[0] ?? description;
    const trimmed = firstSentence.trim().replace(/\s+/g, " ");
    return trimmed.length > SUMMARY_MAX_CHARS
        ? trimmed.slice(0, SUMMARY_MAX_CHARS - 1) + "…"
        : trimmed;
}
/**
 * Render the deferred-tool catalog, byte-stable (name-sorted input) and
 * char-budgeted with a degradation ladder: full one-liners → name-only lines
 * → truncated with a count note. Entries are never silently dropped without
 * the note, so the model always knows the catalog's true size.
 */
function renderCatalog(entries) {
    const full = entries.map((e) => `- ${e.name}: ${e.summary}`);
    let text = full.join("\n");
    if (text.length <= CATALOG_CHAR_BUDGET) {
        return text;
    }
    const nameOnly = entries.map((e) => `- ${e.name}`);
    text = nameOnly.join("\n");
    if (text.length <= CATALOG_CHAR_BUDGET) {
        return text;
    }
    const kept = [];
    let used = 0;
    for (const line of nameOnly) {
        if (used + line.length + 1 > CATALOG_CHAR_BUDGET - 80) {
            break;
        }
        kept.push(line);
        used += line.length + 1;
    }
    const remaining = entries.length - kept.length;
    return `${kept.join("\n")}\n… and ${remaining} more (search by task description to find them)`;
}
/** Lexical relevance score of one catalog entry for a query. Zero = no match. */
function scoreEntry(queryLower, queryTokens, entry, fullDescription) {
    const nameLower = entry.name.toLowerCase();
    if (nameLower === queryLower) {
        return 100;
    }
    let score = 0;
    if (queryLower.includes(nameLower) || nameLower.includes(queryLower)) {
        score += 20;
    }
    const haystack = `${nameLower} ${fullDescription.toLowerCase()}`;
    for (const token of queryTokens) {
        if (token.length < 3) {
            continue;
        }
        if (nameLower.includes(token)) {
            score += 8;
        }
        else if (haystack.includes(token)) {
            score += 2;
        }
    }
    if (entry.serverId && queryLower.includes(entry.serverId.toLowerCase())) {
        score += 3;
    }
    return score;
}
/**
 * Partition a (post-gate) tools record for discovery: deferrable tools that
 * are not pinned are removed from the record and replaced by one
 * `search_tools` meta-tool. Returns the hot record; when nothing is
 * deferrable the input record is returned unchanged.
 *
 * The returned record is mutated by search_tools on hydration (call-scoped —
 * BaseProvider builds a fresh merged record per call), which is what makes
 * discovered tools callable on subsequent steps of the same agent loop on
 * providers that re-read the record between steps.
 */
export function partitionToolsForDiscovery(tools, args) {
    const deferredNames = args.deferrableNames
        .filter((n) => n in tools && !args.pinnedNames.has(n))
        .sort();
    if (deferredNames.length === 0) {
        return tools;
    }
    // Never shadow a real tool that happens to be named "search_tools" (a
    // custom tool or an external MCP tool could claim the name). Discovery is
    // skipped for the request rather than silently replacing the caller's tool.
    if ("search_tools" in tools) {
        warnSearchToolsCollisionOnce();
        return tools;
    }
    const deferredSet = new Set(deferredNames);
    // Null prototype: a tool named "__proto__" must become an own entry, not a
    // prototype mutation (mirrors BaseProvider.sortToolRecord) — this record is
    // also mutated later by search_tools hydration.
    const hot = Object.create(null);
    for (const [name, toolDef] of Object.entries(tools)) {
        if (!deferredSet.has(name)) {
            hot[name] = toolDef;
        }
    }
    const entries = deferredNames.map((name) => {
        const t = tools[name];
        return {
            name,
            summary: summarize(t.description ?? ""),
            serverId: typeof t.serverId === "string" ? t.serverId : undefined,
        };
    });
    hot["search_tools"] = buildSearchTool(entries, tools, hot, args.onHydrate);
    logger.debug("[ToolDiscovery] Deferred external tools behind search_tools", {
        hotCount: Object.keys(hot).length - 1,
        deferredCount: deferredNames.length,
        pinnedCount: args.pinnedNames.size,
    });
    return hot;
}
function buildSearchTool(entries, allTools, hotRecord, onHydrate) {
    const catalog = renderCatalog(entries);
    const searchTool = createAISDKTool({
        description: `Search and load additional tools on demand. ${entries.length} more tools are available but not yet loaded:\n` +
            `${catalog}\n` +
            `Call this with a task description or an exact tool name; matching tools are loaded and become directly callable.`,
        inputSchema: z.object({
            query: z
                .string()
                .describe("What you need to do, or an exact tool name to load"),
        }),
        execute: async ({ query }) => {
            const queryLower = query.toLowerCase().trim();
            const queryTokens = queryLower.split(/[^a-z0-9_.-]+/).filter(Boolean);
            const scored = entries
                .map((entry) => {
                const full = allTools[entry.name];
                return {
                    entry,
                    score: scoreEntry(queryLower, queryTokens, entry, full?.description ?? ""),
                };
            })
                .filter((s) => s.score > 0)
                .sort((a, b) => b.score - a.score || (a.entry.name < b.entry.name ? -1 : 1))
                .slice(0, MAX_SEARCH_RESULTS);
            if (scored.length === 0) {
                return {
                    found: 0,
                    message: "No tools matched. Try different words, or use one of the exact names from the catalog in this tool's description.",
                };
            }
            const loaded = scored.map(({ entry }) => {
                const toolDef = allTools[entry.name];
                // Live hydration: callable on subsequent loop steps where the
                // provider re-reads the record.
                hotRecord[entry.name] = toolDef;
                const def = toolDef;
                return {
                    name: entry.name,
                    description: def.description ?? "",
                    inputSchema: convertZodToJsonSchema(def.inputSchema),
                };
            });
            // Session pinning: included in full on every subsequent call.
            onHydrate(loaded.map((t) => t.name));
            logger.debug("[ToolDiscovery] search_tools hydrated tools", {
                query,
                loaded: loaded.map((t) => t.name),
            });
            return {
                found: loaded.length,
                tools: loaded,
                message: "These tools are now loaded for this session. Call them directly by name with arguments matching their inputSchema. If a call reports the tool as unavailable, it will be available from the next message onward.",
            };
        },
    });
    searchTool[DISCOVERY_META_TOOL_MARKER] = true;
    return searchTool;
}
//# sourceMappingURL=toolDiscovery.js.map