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