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

195 lines (194 loc) • 8.25 kB
/** * Tool routing by decision model. * * The shipped router asks a generative model for `{servers: string[]}` on a * 15-second budget. That shape cannot express uncertainty: a server is in the * list or it is not, and the only recourse for a model that is unsure is to * include it. A decision model answers one yes/no question per server and * returns a *calibrated probability* for each, in one round trip of ~400ms * costing ~$0.00002. * * The asymmetry that follows is the whole point. Keeping a server that was not * needed costs a few hundred tokens of tool definitions. Dropping a server * that WAS needed costs the turn — the model cannot call a tool it was never * shown, and it has no way to ask for it back. So a server is excluded only on * a *confident no*; unanswered, mistyped and near-coin-flip answers all keep * it. This is the same reasoning as the classifier's asymmetric upgrade and * downgrade bars, applied to a different decision. * * Fail-open in every direction: returns `null` whenever the caller should * carry on as if this module did not exist. * * @module core/toolRoutingDecision */ import { decisionKey, gateDecisionBoolean } from "../utils/decisionAnswers.js"; import { logger } from "../utils/logger.js"; /** Namespace for the per-server questions in the batch. */ const SERVER_NAMESPACE = "server"; /** * How confidently the model must answer "no" before a server's tools are * withheld. Deliberately high — a wrongly dropped server breaks the turn, * while a wrongly kept one costs tokens. */ const DEFAULT_MIN_DROP_CONFIDENCE = 0.6; /** * Upper bound on the query text sent as state. The query is untrusted and may * attempt injection; the blast radius is bounded (the worst outcome is keeping * MORE already-registered tools, since ids are never read back off the wire — * answers are matched by position), but an unbounded state would still be * billed and could push the request past the model's input ceiling. */ const MAX_STATE_CHARS = 10000; /** * Cap on servers asked about in one batch. Latency is flat in question count, * so this is purely an input-size guard: state plus all questions must stay * under the ~64K request ceiling. */ const MAX_SERVERS = 200; /** * The question wording, and why it is this wording. * * The obvious phrasing — "answering this request will require calling at least * one tool from this server", with a `false` criterion reading "this server is * unrelated, OR the request needs no tool at all" — was measured against a * 10-request × 5-server labelled set. It separated correctly but weakly: * unrelated servers averaged p=0.31 and reached 0.80, so at the 0.6 drop bar * only 12 of 39 unneeded servers were dropped. * * Naming the server, asking in the present tense about what carrying out the * request *involves*, and splitting the bundled `false` criterion into a single * claim moved unrelated servers to a mean of p=0.03 with a maximum of 0.35 — * **37 of 39** dropped at the same bar, still with zero wrong drops. * * The lesson generalises: a decision model reads literally, and an `or` in a * criterion is two questions wearing one coat. */ function serverQuestion(server) { const does = uncapitalize(describeServer(server)); return { type: "boolean", instructions: `The request needs the "${server.id}" server, which can ${does}.`, criteria: { true: `Carrying out the request involves ${does}.`, false: `The request is about something else; nothing it asks for involves ${does}.`, }, }; } /** * Lowercase only the FIRST character. A plain `toLowerCase()` would turn * "Read and write GitHub issues" into "…github issues", and the proper noun is * often the strongest signal in the sentence. */ function uncapitalize(text) { // charAt rather than an index: it returns "" for an empty string, so this // needs no length guard and no non-null assertion. return text.charAt(0).toLowerCase() + text.slice(1); } /** * Ask one yes/no question per routable server and return the servers to keep. * * Returns `null` when the caller should fall through to its existing path: * no decision provider, a failed call, fewer than two servers, or an answer * set that would change nothing. */ export async function selectServersByDecision(userQuery, routableServers, decide, options) { if (!userQuery || routableServers.length < 2) { return null; } const servers = routableServers.slice(0, MAX_SERVERS); const questions = {}; servers.forEach((server, index) => { questions[decisionKey(SERVER_NAMESPACE, index)] = serverQuestion(server); }); const result = await decide({ // Structured state rather than a prompt string: the vendor accepts JSON, // and naming the fields is what stops the model reading the catalogue as // part of the user's request. state: { request: userQuery.slice(0, MAX_STATE_CHARS), available_servers: servers.map((server) => ({ name: server.id, does: describeServer(server), tool_count: server.toolNames.length, })), }, questions, timeoutMs: options?.timeoutMs, }); if (!result) { return null; } const minConfidence = options?.minDropConfidence ?? DEFAULT_MIN_DROP_CONFIDENCE; const selectedServerIds = []; const excludedServerIds = []; let answered = 0; servers.forEach((server, index) => { const verdict = gateDecisionBoolean(result.answers, decisionKey(SERVER_NAMESPACE, index), { minConfidence }); if (verdict !== undefined) { answered += 1; } // Only a confident `false` drops a server. `undefined` (unanswered, wrong // type, or too close to a coin flip) and `true` both keep it. if (verdict === false) { excludedServerIds.push(server.id); } else { selectedServerIds.push(server.id); } }); // Servers past the cap were never asked about, so they are kept — a server // that was not offered to the model must never be dropped by its silence. for (const server of routableServers.slice(MAX_SERVERS)) { selectedServerIds.push(server.id); } if (answered === 0) { logger.debug("[ToolRouting] Decision router returned no usable answers", { routableServerCount: routableServers.length, }); return null; } if (excludedServerIds.length === 0) { // Nothing to exclude is indistinguishable from routing being off, and // reporting it as "applied" would make the telemetry claim a narrowing // that did not happen. return null; } const excludedSet = new Set(excludedServerIds); const excludedToolNames = routableServers .filter((server) => excludedSet.has(server.id)) .flatMap((server) => server.toolNames); logger.debug("[ToolRouting] Decision router applied", { model: result.model, latencyMs: result.latencyMs, askedServerCount: servers.length, answeredCount: answered, keptServerCount: selectedServerIds.length, excludedServerCount: excludedServerIds.length, excludedToolCount: excludedToolNames.length, }); return { selectedServerIds, excludedServerIds, excludedToolNames, answeredCount: answered, model: result.model, latencyMs: result.latencyMs, }; } /** * One line describing what a server is for. Falls back to naming its tools * when the host declared no description, since a bare id ("fs", "gh") carries * almost no signal and the tool names usually carry a great deal. */ function describeServer(server) { if (server.description && server.description.trim().length > 0) { return server.description; } const names = server.toolNames .map((name) => name.slice(server.id.length + 1)) .filter((name) => name.length > 0) .slice(0, 12); return names.length > 0 ? `provides the tools: ${names.join(", ")}` : `the "${server.id}" server`; }