@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
175 lines (174 loc) • 7.09 kB
JavaScript
/**
* 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 server capability already exists once in structured state. Referencing
* that indexed field avoids copying the same long description into the
* instruction and both criteria while still binding each question to one
* concrete server.
*/
function serverQuestion(server, index) {
return {
type: "boolean",
instructions: `Does the request need the "${server.id}" server? Read available_servers[${index}].does.`,
criteria: {
true: "Needed.",
false: "Not needed.",
},
};
}
/**
* 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, index);
});
const result = await decide({
site: "toolRouting",
// 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`;
}