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

303 lines (302 loc) • 13 kB
/** * 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 — the AI SDK path re-reads the * record between agent-loop steps, and the native loops (Vertex * Gemini/Claude, AI Studio, chat-completions, direct Anthropic) re-resolve * against it on a dispatch miss and refresh their wire declarations per * step — so discovered tools are callable on the very next step, and * - into the session pin set (all providers include them in full on every * subsequent call of the session). * * The record additionally carries a symbol-keyed {@link DeferredToolResolver} * (see `resolveDeferredTool`) so a loop can hydrate a cataloged tool the * model called directly by name WITHOUT a prior search_tools call — the * catalog advertises exact names, so models legitimately do this. * * 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"; /** * Symbol key under which the hot record carries its deferred-tool resolver. * Symbol-keyed properties are skipped by Object.keys/entries, for-in, and * JSON.stringify, so no declaration builder (or the AI SDK) ever sees the * resolver as a tool. `Symbol.for` (global registry) keeps the key stable * even if this module is loaded twice (ESM/CJS dual load of the bundle). */ const DEFERRED_TOOL_RESOLVER_KEY = Symbol.for("neurolink.deferredToolResolver"); /** * 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); // Dispatch-miss escape hatch for the native agent loops: hydrate a // deferred tool the model called directly by its cataloged name (the // catalog advertises exact names, so this is legitimate model behavior, // not hallucination). Hydration mutates the hot record and persists the // session pin — identical side effects to a search_tools hit. const resolver = (name) => { if (name in hot) { return hot[name]; } if (!deferredSet.has(name)) { return undefined; } const toolDef = tools[name]; if (!toolDef) { return undefined; } hot[name] = toolDef; args.onHydrate([name]); logger.debug("[ToolDiscovery] Auto-hydrated deferred tool on direct call", { name, }); return toolDef; }; Object.defineProperty(hot, DEFERRED_TOOL_RESOLVER_KEY, { value: resolver, enumerable: false, writable: false, configurable: true, }); logger.debug("[ToolDiscovery] Deferred external tools behind search_tools", { hotCount: Object.keys(hot).length - 1, deferredCount: deferredNames.length, pinnedCount: args.pinnedNames.size, }); return hot; } /** * Hydrate-and-return a deferred tool by name, when `record` was produced by * `partitionToolsForDiscovery` and `name` is in its deferred catalog. * Returns `undefined` for records without a resolver (discovery off) and for * names that are genuinely unknown — callers keep their hallucinated-name * handling for that case. */ export function resolveDeferredTool(record, name) { if (!record) { return undefined; } const resolver = record[DEFERRED_TOOL_RESOLVER_KEY]; return typeof resolver === "function" ? resolver(name) : undefined; } /** * Live tool lookup for native agent loops on a dispatch miss: first re-reads * the record (a tool hydrated by search_tools after the loop built its * pre-step snapshot), then falls back to auto-hydrating from the deferred * catalog. Returns `undefined` only when the name is genuinely unknown. */ export function resolveLiveTool(record, name) { if (!record) { return undefined; } return record[name] ?? resolveDeferredTool(record, name); } 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.", }; }, }); searchTool[DISCOVERY_META_TOOL_MARKER] = true; return searchTool; }