@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
391 lines (390 loc) • 17.1 kB
JavaScript
/**
* Host-loop delegation (N5): `registerAgentTool()` wraps an isolated agent
* (runIsolatedAgent) as a tool on the HOST NeuroLink instance, so the host's
* EXISTING generate() loop delegates — never a second router generate.
*
* Framework policy lives here:
* - per-turn delegation caps, counted per top-level generate() in the loop
* itself (AsyncLocalStorage turn scope);
* - depth limits via tool context (`agentDepth`) — at the limit the tool is
* withheld from the request entirely;
* - a process-wide concurrency pool with queue timeout, shared by every
* registered agent tool;
* - every refusal carries its recovery instruction in the error text.
*/
import { AsyncLocalStorage } from "async_hooks";
import { logger } from "../utils/logger.js";
import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
import { hasOpenIsolatedAgentHandle, runIsolatedAgent, } from "./isolatedAgentRunner.js";
const DEFAULT_POOL_CAPACITY = 4;
const DEFAULT_POOL_QUEUE_TIMEOUT_MS = 30_000;
/** Bound on the content text returned into the host loop per delegation. */
export const DELEGATION_RESULT_CONTENT_CHARS = 4000;
// ── Registrations ──────────────────────────────────────────────────────────
const hostRegistrations = new WeakMap();
function registrationsFor(host) {
let map = hostRegistrations.get(host);
if (!map) {
map = new Map();
hostRegistrations.set(host, map);
}
return map;
}
// ── Per-turn scope (delegation caps) ───────────────────────────────────────
const turnStorage = new AsyncLocalStorage();
/**
* Enter a per-turn delegation scope for a top-level generate(). Returns null
* when a scope is already active (nested/internal generates share the
* top-level turn's counters) — the caller then proceeds without wrapping.
*/
export function beginDelegationTurn(host, options) {
if (turnStorage.getStore()) {
return null;
}
const registrations = registrationsFor(host);
let scopedOptions = options;
if (registrations.size > 0 && typeof options !== "string") {
const depth = resolveDepth(host, options);
const withheld = [...registrations.values()]
.filter((r) => r.options.maxDepth !== undefined && depth >= r.options.maxDepth)
.map((r) => r.name);
if (withheld.length > 0) {
const generateOptions = options;
scopedOptions = {
...generateOptions,
excludeTools: [...(generateOptions.excludeTools ?? []), ...withheld],
};
logger.debug("[AgentToolRegistrar] Withholding depth-limited agent tools", { withheld, depth });
}
}
const state = {
counts: new Map(),
depth: resolveDepth(host, typeof options === "string" ? undefined : options),
};
return {
options: scopedOptions,
run: (fn) => turnStorage.run(state, fn),
};
}
/**
* Run `fn` one delegation level deeper, sharing the current turn's counters.
* Used by AgentNetwork's standalone delegations: the network holds a pool
* slot around each agent execution, so any registered-agent-tool delegation
* the agent makes from inside must take the NESTED path (pool bypass, depth
* checks) — request-scoped via AsyncLocalStorage, never instance state.
*/
export function runWithNestedDelegationDepth(fn) {
const store = turnStorage.getStore();
const state = store
? { counts: store.counts, depth: store.depth + 1 }
: { counts: new Map(), depth: 1 };
return turnStorage.run(state, fn);
}
/**
* The delegation depth a tool call is being made AT — execution context
* first (a worker running the host's registered closure arrives with its own
* `agentDepth`), then the per-turn ALS scope, then the host's own context.
*
* Exported so every delegation surface answers "how deep am I?" the same
* way; `executeDelegation` and the background form both call it.
*/
export function resolveDelegationDepth(host, executionContext) {
const fromExecution = executionContext?.agentDepth;
if (typeof fromExecution === "number") {
return fromExecution;
}
const turnScope = turnStorage.getStore();
return turnScope?.depth ?? resolveDepth(host);
}
/**
* Depth declared by the CALL or the instance. The execution context — which
* carries the agentDepth stamped on the worker actually making the call — is
* checked one level up, in {@link resolveDelegationDepth}.
*/
function resolveDepth(host, options) {
const fromCall = options?.context?.agentDepth;
if (typeof fromCall === "number") {
return fromCall;
}
const fromInstance = host.getToolContext()?.agentDepth;
return typeof fromInstance === "number" ? fromInstance : 0;
}
// ── Process-wide delegation pool ───────────────────────────────────────────
let poolCapacity = DEFAULT_POOL_CAPACITY;
let poolInUse = 0;
const poolWaiters = [];
function releasePoolSlot() {
poolInUse = Math.max(0, poolInUse - 1);
const next = poolWaiters.shift();
if (next) {
poolInUse++;
next.grant();
}
}
/**
* Raise the process-wide delegation pool to at least `capacity` slots.
*
* Raises only — never lowers. The pool is shared by every delegation surface,
* so a per-agent "max concurrent" that could shrink it would be a throttle on
* everyone else's work. Queued waiters the raise just unblocked are granted
* immediately, so a raise never leaves capacity idle behind a full queue.
*/
export function raiseDelegationPoolCapacity(capacity) {
// Infinity historically meant "unbounded" (Math.max accepted it); map it to
// a finite stand-in rather than silently dropping to the default. NaN stays
// rejected — it used to deadlock the pool permanently (Math.max(4, NaN) is
// NaN, and `poolInUse < NaN` is always false).
const UNBOUNDED_STAND_IN = 1024;
if (capacity === Number.POSITIVE_INFINITY) {
capacity = UNBOUNDED_STAND_IN;
}
if (!Number.isFinite(capacity) || capacity <= poolCapacity) {
return;
}
poolCapacity = capacity;
while (poolInUse < poolCapacity && poolWaiters.length > 0) {
const next = poolWaiters.shift();
if (next) {
poolInUse++;
next.grant();
}
}
}
/**
* Take a pool slot only if one is free right now. Returns the release
* function, or undefined when the pool is full — never queues.
*
* Background delegation needs the answer synchronously: `spawnDelegate`
* returns a handle BEFORE the worker starts, and the handle reports whether
* the worker is running or waiting for a slot. Asking after the fact would be
* a guess.
*/
export function tryAcquireDelegationSlot() {
if (poolInUse >= poolCapacity) {
return undefined;
}
poolInUse++;
let released = false;
return () => {
if (!released) {
released = true;
releasePoolSlot();
}
};
}
/**
* Acquire a slot in the process-wide delegation pool, waiting up to
* `timeoutMs` in the queue. Resolves to a release function.
*/
export function acquireDelegationSlot(timeoutMs = DEFAULT_POOL_QUEUE_TIMEOUT_MS) {
if (poolInUse < poolCapacity) {
poolInUse++;
let released = false;
return Promise.resolve(() => {
if (!released) {
released = true;
releasePoolSlot();
}
});
}
return new Promise((resolve, reject) => {
const waiter = {
grant: () => {
clearTimeout(timer);
let released = false;
resolve(() => {
if (!released) {
released = true;
releasePoolSlot();
}
});
},
cancel: (reason) => {
clearTimeout(timer);
reject(reason);
},
};
const timer = setTimeout(() => {
const index = poolWaiters.indexOf(waiter);
if (index !== -1) {
poolWaiters.splice(index, 1);
}
waiter.cancel(new Error("delegation pool queue timeout"));
}, timeoutMs);
timer.unref?.();
poolWaiters.push(waiter);
});
}
// ── Refusals (recovery instruction always included) ────────────────────────
function refusal(message) {
return { isError: true, error: message };
}
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Register an isolated agent as a delegation tool on the host instance.
* The tool becomes available to every generate()/stream() the host runs —
* inside its existing loop, no second router generate.
*/
export function registerAgentTool(host, definition, options = {}) {
if (!definition?.id || !definition?.instructions) {
throw new Error("registerAgentTool: definition must include id and instructions");
}
const name = options.name ?? definition.id;
const registrations = registrationsFor(host);
if (registrations.has(name)) {
throw new Error(`registerAgentTool: an agent tool named "${name}" is already registered on this instance`);
}
if (options.maxConcurrent !== undefined) {
raiseDelegationPoolCapacity(options.maxConcurrent);
}
const registration = {
name,
definition,
options,
};
registrations.set(name, registration);
const inputSchema = definition.inputSchema
? convertZodToJsonSchema(definition.inputSchema)
: {
type: "object",
properties: {
task: {
type: "string",
description: "The task to delegate to this agent",
},
},
required: ["task"],
};
host.registerTool(name, {
name,
description: `Delegate to agent: ${definition.name} — ${definition.description}`,
inputSchema: inputSchema,
execute: async (params, context) => {
return executeDelegation(host, registration, params, context);
},
});
logger.debug("[AgentToolRegistrar] Registered agent tool", {
name,
agentId: definition.id,
maxDelegationsPerTurn: options.maxDelegationsPerTurn,
maxDepth: options.maxDepth,
});
return { name };
}
async function executeDelegation(host, registration, params, context) {
const { name, definition, options } = registration;
const contextRecord = context && typeof context === "object"
? context
: {};
// Depth comes from the EXECUTION context first: when a worker created with
// the shared tool registry delegates, the registration lives on the host
// but the call arrives with the worker's tool context carrying the
// agentDepth this registrar set — the host instance context would read 0
// every time and nested delegation would never hit the limit. When the
// execution context carries no depth, the ALS turn scope's depth covers
// the composed path (AgentNetwork wraps its agents one level deeper).
const turnScope = turnStorage.getStore();
const depth = resolveDelegationDepth(host, contextRecord);
if (options.maxDepth !== undefined && depth >= options.maxDepth) {
return refusal(`Delegation depth limit reached (${depth}/${options.maxDepth}). Complete this investigation yourself with your own tools instead of delegating further.`);
}
const sessionId = typeof contextRecord.sessionId === "string"
? contextRecord.sessionId
: undefined;
// Open-handle conflict: an in-progress leashed leg for this agent, owned
// by THIS host + caller session, must be continued, not restarted. Other
// sessions' handles are invisible here — the pool bounds cross-session
// concurrency.
const openHandle = hasOpenIsolatedAgentHandle(host, definition.id, sessionId);
if (openHandle.open) {
return refusal(`Agent "${definition.id}" already has an in-progress investigation (handle ${openHandle.handle}); continue it via its handle instead of delegating anew.`);
}
// Per-turn cap — counted in the loop itself, per top-level generate. The
// count check runs before the pool wait, but the count is CONSUMED only
// once a run actually starts: a refusal (depth/handle/pool) must not burn
// one of the model's delegations. Nested scopes share the top-level
// turn's counters (see runWithNestedDelegationDepth).
const turnState = turnScope;
if (options.maxDelegationsPerTurn !== undefined && turnState) {
const used = turnState.counts.get(name) ?? 0;
if (used >= options.maxDelegationsPerTurn) {
return refusal(`Delegation limit reached: ${name} has already been called ${used}× this turn (max ${options.maxDelegationsPerTurn}). Do not call ${name} again this turn; synthesize from the investigations you already have.`);
}
}
// Nested delegations bypass the pool: the outer delegation already holds a
// slot, so queueing nested work behind a full pool would deadlock it (all
// slots held by outers waiting on their inners).
let release;
if (depth === 0) {
try {
release = await acquireDelegationSlot(options.poolQueueTimeoutMs ?? DEFAULT_POOL_QUEUE_TIMEOUT_MS);
}
catch {
return refusal(`All delegation slots are busy and the queue timed out. Do not retry ${name} immediately; continue with your own tools or synthesize from the investigations you already have.`);
}
}
try {
// Consume the per-turn count only now that the run actually starts.
if (options.maxDelegationsPerTurn !== undefined && turnState) {
turnState.counts.set(name, (turnState.counts.get(name) ?? 0) + 1);
}
// Default task-string schema: unwrap `task`; a bare string passes
// through; any other object passes through whole (runIsolatedAgent
// JSON-stringifies structured input — never "[object Object]").
const input = definition.inputSchema
? params
: typeof params?.task === "string"
? params.task
: typeof params === "string"
? params
: (params ?? {});
const abortSignal = contextRecord
.abortSignal;
const outcome = await runIsolatedAgent(host, definition, input, {
...(abortSignal && { abortSignal }),
...(options.leg && { leg: options.leg }),
...(options.handleTtlMs !== undefined && {
handleTtlMs: options.handleTtlMs,
}),
...(options.waste && { waste: options.waste }),
...(options.overrides && { overrides: options.overrides }),
toolContext: {
agentDepth: depth + 1,
...(sessionId && { sessionId }),
},
});
// Bounded summary into the host loop — full records stay on the outcome
// for programmatic callers; the model gets the essentials.
return {
status: outcome.status,
...(outcome.data !== undefined && { data: outcome.data }),
...(outcome.content && {
content: outcome.content.length > DELEGATION_RESULT_CONTENT_CHARS
? `${outcome.content.slice(0, DELEGATION_RESULT_CONTENT_CHARS)}…`
: outcome.content,
}),
...(outcome.stopReason && { stopReason: outcome.stopReason }),
toolCallsUsed: outcome.toolExecutions.length,
durationMs: outcome.durationMs,
...(outcome.handle && { handle: outcome.handle }),
...(outcome.delta && { delta: outcome.delta }),
...(outcome.nextPlan && { nextPlan: outcome.nextPlan }),
...(outcome.wasteSignals && { wasteSignals: outcome.wasteSignals }),
...(outcome.budget && { budget: outcome.budget }),
};
}
catch (error) {
return refusal(`Delegation to ${name} failed: ${error instanceof Error ? error.message : String(error)}. Continue with your own tools or synthesize from what you already have.`);
}
finally {
release?.();
}
}
/** Test-only: reset the process-wide pool. */
export function resetDelegationPoolForTests(capacity) {
poolCapacity = capacity ?? DEFAULT_POOL_CAPACITY;
poolInUse = 0;
poolWaiters
.splice(0, poolWaiters.length)
.forEach((w) => w.cancel(new Error("pool reset")));
}