@juspay/neurolink 13.5.1 → 13.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -152,7 +152,9 @@ export declare abstract class BaseProvider implements AIProvider {
152
152
  private sortToolRecord;
153
153
  /**
154
154
  * On-demand discovery (`tools.discovery: true`): defer external MCP tool
155
- * schemas behind one `search_tools` meta-tool. Built-in tools, per-call
155
+ * schemas — or, with `tools.discoveryScope: "all"`, every non-built-in
156
+ * tool — behind one `search_tools` meta-tool, except names matching
157
+ * `tools.discoveryAlwaysVisible`. Built-in tools, per-call
156
158
  * tools, explicitly whitelisted tools, and session-pinned (previously
157
159
  * discovered) tools always stay hot. No-op when discovery is off — with a
158
160
  * one-time WARN when the catalog is large enough that selection accuracy
@@ -1246,7 +1246,9 @@ export class BaseProvider {
1246
1246
  }
1247
1247
  /**
1248
1248
  * On-demand discovery (`tools.discovery: true`): defer external MCP tool
1249
- * schemas behind one `search_tools` meta-tool. Built-in tools, per-call
1249
+ * schemas — or, with `tools.discoveryScope: "all"`, every non-built-in
1250
+ * tool — behind one `search_tools` meta-tool, except names matching
1251
+ * `tools.discoveryAlwaysVisible`. Built-in tools, per-call
1250
1252
  * tools, explicitly whitelisted tools, and session-pinned (previously
1251
1253
  * discovered) tools always stay hot. No-op when discovery is off — with a
1252
1254
  * one-time WARN when the catalog is large enough that selection accuracy
@@ -1273,8 +1275,13 @@ export class BaseProvider {
1273
1275
  }
1274
1276
  return tools;
1275
1277
  }
1276
- const externalTools = this.neurolink?.getExternalMCPTools() ?? [];
1277
- if (externalTools.length === 0) {
1278
+ // "external" defers only attached MCP servers' tools; "all" defers every
1279
+ // tool in the record except the built-ins.
1280
+ const builtinNames = new Set(Object.keys(this.directTools ?? {}));
1281
+ const candidateNames = policy.discoveryScope === "all"
1282
+ ? Object.keys(tools).filter((name) => !builtinNames.has(name))
1283
+ : (this.neurolink?.getExternalMCPTools() ?? []).map((t) => t.name);
1284
+ if (candidateNames.length === 0) {
1278
1285
  return tools;
1279
1286
  }
1280
1287
  // Session pinning requires a caller-provided sessionId. Without one there
@@ -1305,12 +1312,15 @@ export class BaseProvider {
1305
1312
  explicitPatterns.push(toolChoice.toolName);
1306
1313
  }
1307
1314
  const explicitMatcher = explicitPatterns.length > 0 ? toolNameMatcher(explicitPatterns) : null;
1308
- const deferrableNames = externalTools
1309
- .map((t) => t.name)
1310
- .filter((name) => name in tools &&
1315
+ const alwaysVisiblePatterns = policy.discoveryAlwaysVisible ?? [];
1316
+ const alwaysVisibleMatcher = alwaysVisiblePatterns.length > 0
1317
+ ? toolNameMatcher(alwaysVisiblePatterns)
1318
+ : null;
1319
+ const deferrableNames = candidateNames.filter((name) => name in tools &&
1311
1320
  !perCallNames.has(name) &&
1312
- !(explicitMatcher ? explicitMatcher(name) : false));
1313
- return partitionToolsForDiscovery(tools, {
1321
+ !(explicitMatcher ? explicitMatcher(name) : false) &&
1322
+ !(alwaysVisibleMatcher ? alwaysVisibleMatcher(name) : false));
1323
+ const partitioned = partitionToolsForDiscovery(tools, {
1314
1324
  deferrableNames,
1315
1325
  pinnedNames,
1316
1326
  onHydrate: (names) => {
@@ -1319,6 +1329,16 @@ export class BaseProvider {
1319
1329
  }
1320
1330
  },
1321
1331
  });
1332
+ // The meta-tool is built after the event wrappers ran, so without this
1333
+ // a search_tools call never reaches tool:start / tool:end listeners.
1334
+ const metaTool = partitioned["search_tools"];
1335
+ if (isDiscoveryMetaTool(metaTool)) {
1336
+ partitioned["search_tools"] =
1337
+ this.toolsManager.wrapExternalToolsWithEvents({
1338
+ search_tools: metaTool,
1339
+ })["search_tools"];
1340
+ }
1341
+ return partitioned;
1322
1342
  }
1323
1343
  static warnedLargeCatalog = false;
1324
1344
  warnLargeCatalogOnce(toolCount) {
@@ -512,6 +512,14 @@ export class ToolsManager {
512
512
  const idField = typeof toolCallId === "string" && toolCallId.length > 0
513
513
  ? { toolCallId }
514
514
  : {};
515
+ // Same per-invocation id executeTool gives registered tools, so a
516
+ // listener can pair this start with its end.
517
+ const eventIds = {
518
+ ...idField,
519
+ executionId: `${toolName}-${Date.now()}-${Math.random()
520
+ .toString(36)
521
+ .slice(2, 11)}`,
522
+ };
515
523
  return withSpan({
516
524
  name: toolSpanName(toolName),
517
525
  tracer: tracers.mcp,
@@ -521,7 +529,7 @@ export class ToolsManager {
521
529
  const startTime = Date.now();
522
530
  this.emitToolEvent("tool:start", toolName, {
523
531
  input: params,
524
- ...idField,
532
+ ...eventIds,
525
533
  });
526
534
  let result;
527
535
  try {
@@ -533,7 +541,7 @@ export class ToolsManager {
533
541
  error: errorMsg,
534
542
  success: false,
535
543
  responseTime: Date.now() - startTime,
536
- ...idField,
544
+ ...eventIds,
537
545
  });
538
546
  throw error;
539
547
  }
@@ -549,7 +557,7 @@ export class ToolsManager {
549
557
  success: isErrorText === undefined,
550
558
  ...(isErrorText !== undefined ? { error: isErrorText } : {}),
551
559
  responseTime: Date.now() - startTime,
552
- ...idField,
560
+ ...eventIds,
553
561
  });
554
562
  return result;
555
563
  });
@@ -84,6 +84,8 @@ export declare const toPrepareStepRecord: (step: {
84
84
  export declare const hasNativeDoGenerate: (value: unknown) => value is {
85
85
  doGenerate: (options: Record<string, unknown>) => Promise<Record<string, unknown>>;
86
86
  };
87
+ /** A tool's inputSchema with its lazy (function) form, if any, resolved. */
88
+ export declare const resolveToolInputSchema: (schema: Tool["inputSchema"]) => Exclude<Tool["inputSchema"], (...args: never[]) => unknown>;
87
89
  /**
88
90
  * Spell a JSON Schema into the conversation's system turn.
89
91
  *
@@ -17,6 +17,7 @@ import { observeGeneration } from "../telemetry/observation.js";
17
17
  import { StepSpans, withGeneration } from "../telemetry/withSpan.js";
18
18
  import { logger } from "../utils/logger.js";
19
19
  import { guardToolExecutor } from "./toolExecutionGuards.js";
20
+ import { resolveLiveTool } from "../tools/toolDiscovery.js";
20
21
  const DEFAULT_TOOL_CHOICE_STEPS = 1;
21
22
  /**
22
23
  * A choice that compels a tool call: `"required"` or a named tool, in either
@@ -74,9 +75,9 @@ const warnUndeclaredForcedTool = (toolName, step, source) => {
74
75
  };
75
76
  /**
76
77
  * The names this request actually carries, in the record shape
77
- * `declaredTools` takes. The loop sends `args.tools`, built once by the
78
- * caller; `args.toolsRecord` can hold more (discovery hydrates into it, and
79
- * this loop never re-declares), so only `tools` answers "is it offered".
78
+ * `declaredTools` takes. `args.toolsRecord` can hold more than is declared
79
+ * (discovery hydrates into it), so only the sent declarations answer "is it
80
+ * offered".
80
81
  * Null prototype: a tool may be named `__proto__` or `constructor`.
81
82
  */
82
83
  const declaredToolRecord = (tools) => {
@@ -336,6 +337,8 @@ const parseToolInput = (raw) => {
336
337
  return { input: {}, error: "arguments were not valid JSON" };
337
338
  }
338
339
  };
340
+ /** A tool's inputSchema with its lazy (function) form, if any, resolved. */
341
+ export const resolveToolInputSchema = (schema) => typeof schema === "function" ? resolveToolInputSchema(schema()) : schema;
339
342
  /**
340
343
  * Validate parsed input against the tool's own schema when it exposes one.
341
344
  *
@@ -346,8 +349,12 @@ const parseToolInput = (raw) => {
346
349
  * validator this loop has no business carrying.
347
350
  */
348
351
  const validateToolInput = (tool, input) => {
349
- const schema = tool?.inputSchema;
350
- if (typeof schema?.safeParse !== "function") {
352
+ const schema = tool?.inputSchema
353
+ ? resolveToolInputSchema(tool.inputSchema)
354
+ : undefined;
355
+ if (!schema ||
356
+ !("safeParse" in schema) ||
357
+ typeof schema.safeParse !== "function") {
351
358
  return undefined;
352
359
  }
353
360
  const result = schema.safeParse(input);
@@ -478,11 +485,28 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
478
485
  // answer is that result — not a thrown turn.
479
486
  let preReask;
480
487
  const hasTools = Boolean(args.tools && args.tools.length > 0);
488
+ // The declarations actually sent. Starts as the caller's list and grows when
489
+ // a tool joins toolsRecord mid-turn (discovery loads tools into it).
490
+ const liveTools = args.tools ? [...args.tools] : undefined;
491
+ const declaredNames = declaredToolRecord(args.tools);
481
492
  // Completed-step records for `prepareStep`; one per step that returned.
482
493
  const stepRecords = [];
483
494
  for (let step = 0; step < args.maxSteps; step++) {
484
495
  steps = step + 1;
485
496
  stepSpans.begin(steps);
497
+ if (liveTools && args.declareTool) {
498
+ const loaded = [];
499
+ for (const name of Object.keys(args.toolsRecord)) {
500
+ if (!declaredNames[name]) {
501
+ liveTools.push(args.declareTool(name, args.toolsRecord[name]));
502
+ declaredNames[name] = true;
503
+ loaded.push(name);
504
+ }
505
+ }
506
+ if (loaded.length > 0) {
507
+ logger.debug(`[nativeGenerateLoop] ${loaded.length} tool(s) hydrated mid-turn via discovery: ${loaded.join(", ")}`);
508
+ }
509
+ }
486
510
  // The forced-choice window and the caller's hook are resolved BEFORE the
487
511
  // re-ask override below: the re-ask has to win, since its whole point is
488
512
  // a request with tools disabled.
@@ -498,7 +522,7 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
498
522
  steps: stepRecords,
499
523
  maxSteps: args.maxSteps,
500
524
  model: args.modelId,
501
- declaredTools: declaredToolRecord(args.tools),
525
+ declaredTools: declaredToolRecord(liveTools),
502
526
  ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
503
527
  })
504
528
  : undefined;
@@ -519,7 +543,7 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
519
543
  }
520
544
  const runThisStep = () => generateObservedStep(args, {
521
545
  prompt: args.conversation,
522
- ...(args.tools && args.tools.length > 0 ? { tools: args.tools } : {}),
546
+ ...(liveTools && liveTools.length > 0 ? { tools: liveTools } : {}),
523
547
  // The v3 call option is an OBJECT — `{ type: "none" }`. Passing the
524
548
  // bare string "none" type-checks against `unknown` and is then
525
549
  // dropped by every converter that switches on `choice.type`, so the
@@ -688,7 +712,9 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
688
712
  const startTime = new Date();
689
713
  const parsed = parseToolInput(call.input);
690
714
  const input = parsed.input;
691
- const tool = args.toolsRecord[name];
715
+ // resolveLiveTool also loads a deferred tool the model called by its
716
+ // catalog name without searching first.
717
+ const tool = resolveLiveTool(args.toolsRecord, name);
692
718
  let output;
693
719
  let failure;
694
720
  const rejection = parsed.error ??
@@ -36,7 +36,7 @@ import { NoOutputGeneratedError } from "../utils/generationErrors.js";
36
36
  import { buildNoOutputSentinel, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
37
37
  import { composeAbortSignalsScoped, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../utils/timeout.js";
38
38
  import { resolveRequestKind } from "../core/resolveRequestKind.js";
39
- import { appendJsonSchemaInstruction, hasNativeDoGenerate, resolveStepToolChoice, runNativeGenerateLoop, toPrepareStepRecord, } from "../core/nativeGenerateLoop.js";
39
+ import { appendJsonSchemaInstruction, hasNativeDoGenerate, resolveToolInputSchema, resolveStepToolChoice, runNativeGenerateLoop, toPrepareStepRecord, } from "../core/nativeGenerateLoop.js";
40
40
  import { resolveToolExecutionRecords, toolCallsFromSummaries, } from "../core/toolExecutionRecorder.js";
41
41
  import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
42
42
  import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "../utils/json/coerce.js";
@@ -925,18 +925,19 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
925
925
  ? options.tools || (await this.getAllTools())
926
926
  : {};
927
927
  // v3 tool shape — the same one doGenerate already converts internally.
928
+ // Also handed to the loop, which declares tools discovery loads mid-turn.
929
+ const declareTool = (name, tool) => {
930
+ return {
931
+ type: "function",
932
+ name,
933
+ description: tool.description ?? "",
934
+ inputSchema: tool.inputSchema
935
+ ? convertZodToJsonSchema(resolveToolInputSchema(tool.inputSchema))
936
+ : { type: "object", properties: {} },
937
+ };
938
+ };
928
939
  const v3Tools = shouldUseTools
929
- ? Object.entries(toolsRecord).map(([name, t]) => {
930
- const tool = t;
931
- return {
932
- type: "function",
933
- name,
934
- description: tool.description ?? "",
935
- inputSchema: (tool.inputSchema
936
- ? convertZodToJsonSchema(tool.inputSchema)
937
- : { type: "object", properties: {} }),
938
- };
939
- })
940
+ ? Object.entries(toolsRecord).map(([name, t]) => declareTool(name, t))
940
941
  : undefined;
941
942
  const hasTools = !!v3Tools && v3Tools.length > 0;
942
943
  // Structured output rides response_format, which is what Output.object did
@@ -964,6 +965,14 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
964
965
  // model has already given. Dropping them is what makes `response_format`
965
966
  // legal again on a vendor that refuses the two together.
966
967
  withTools = true) => {
968
+ // What this pass sends: the initial declarations plus any the loop adds
969
+ // for tools discovery loads mid-turn, so the guard counts them too.
970
+ const passTools = withTools && v3Tools ? [...v3Tools] : undefined;
971
+ const declarePassTool = (name, tool) => {
972
+ const declaration = declareTool(name, tool);
973
+ passTools?.push(declaration);
974
+ return declaration;
975
+ };
967
976
  return runNativeGenerateLoop({
968
977
  doGenerate,
969
978
  provider: this.providerName,
@@ -983,12 +992,14 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
983
992
  // the estimate past the reclaim threshold and drop older tool
984
993
  // exchanges out of a reformat request that would have fit.
985
994
  getFixedOverheadTokens: () => estimateTokens(serializeForEstimate({
986
- ...(withTools && v3Tools ? { tools: v3Tools } : {}),
995
+ ...(passTools ? { tools: passTools } : {}),
987
996
  responseFormat: format,
988
997
  }), this.providerName),
989
998
  }),
990
999
  conversation: conv,
991
- ...(withTools && v3Tools ? { tools: v3Tools } : {}),
1000
+ ...(passTools
1001
+ ? { tools: [...passTools], declareTool: declarePassTool }
1002
+ : {}),
992
1003
  toolsRecord,
993
1004
  ...(withTools && hasTools && options.toolChoice
994
1005
  ? { toolChoice: resolveToolChoice(options, toolsRecord, true) }
@@ -244,7 +244,8 @@ export function resolveLiveTool(record, name) {
244
244
  function buildSearchTool(entries, allTools, hotRecord, onHydrate) {
245
245
  const catalog = renderCatalog(entries);
246
246
  const searchTool = createAISDKTool({
247
- description: `Search and load additional tools on demand. ${entries.length} more tools are available but not yet loaded:\n` +
247
+ description: `Search and load additional tools on demand. ${entries.length} more tools are available but not yet loaded. ` +
248
+ `Before telling the user you cannot do something, check this catalog: if a listed tool fits, call it directly by its exact name, or call search_tools with a short description of the task.\n` +
248
249
  `${catalog}\n` +
249
250
  `Call this with a task description or an exact tool name; matching tools are loaded and become directly callable.`,
250
251
  inputSchema: z.object({
@@ -186,8 +186,28 @@ export function resolveToolPolicy(input) {
186
186
  }
187
187
  // --- discovery -----------------------------------------------------------
188
188
  const discovery = instanceConfig?.discovery === true;
189
+ let discoveryScope = "external";
190
+ let discoveryAlwaysVisible = [];
189
191
  if (discovery) {
190
192
  sources.push("tools.discovery");
191
- }
192
- return { enabled, include, includeBound, exclude, discovery, sources };
193
+ const configScope = instanceConfig?.discoveryScope;
194
+ if (configScope === "all") {
195
+ discoveryScope = "all";
196
+ }
197
+ else if (configScope !== undefined && configScope !== "external") {
198
+ warnOnce("shape:tools.discoveryScope", `[ToolPolicy] Ignoring unknown tools.discoveryScope; using "external"`, { received: configScope });
199
+ }
200
+ discoveryAlwaysVisible =
201
+ normalizeConfigList(instanceConfig?.discoveryAlwaysVisible, "tools.discoveryAlwaysVisible", false) ?? [];
202
+ }
203
+ return {
204
+ enabled,
205
+ include,
206
+ includeBound,
207
+ exclude,
208
+ discovery,
209
+ discoveryScope,
210
+ discoveryAlwaysVisible,
211
+ sources,
212
+ };
193
213
  }
@@ -446,6 +446,25 @@ export type ToolConfig = {
446
446
  * Default: false.
447
447
  */
448
448
  discovery?: boolean;
449
+ /**
450
+ * Which tools `discovery` may defer.
451
+ * - `"external"`: tools of servers attached with `addExternalMCPServer`.
452
+ * - `"all"`: every tool in the request's record except the built-in tools,
453
+ * so tools registered with `registerTool`/`registerTools` and in-process
454
+ * MCP tools are deferred as well.
455
+ * Per-call tools, per-call whitelists, forced `toolChoice` tools and
456
+ * already-discovered tools stay hot in both scopes. Ignored unless
457
+ * `discovery` is true.
458
+ * Default: "external".
459
+ */
460
+ discoveryScope?: "external" | "all";
461
+ /**
462
+ * Tool-name patterns (exact names or `*` globs) that `discovery` never
463
+ * defers, in either scope. E.g. `["utility-server_*"]` keeps every tool
464
+ * whose name starts with `utility-server_` in full on every request.
465
+ * Ignored unless `discovery` is true.
466
+ */
467
+ discoveryAlwaysVisible?: string[];
449
468
  /** Whether built-in tools should be disabled (equivalent to excluding all direct tools) */
450
469
  disableBuiltinTools?: boolean;
451
470
  /** Whether custom tools are allowed */
@@ -1839,7 +1839,13 @@ export type NativeGenerateLoopArgs = {
1839
1839
  /** Tool declarations in the v3 shape doGenerate already knows how to convert. */
1840
1840
  tools?: Array<Record<string, unknown>>;
1841
1841
  /** Registered tools, used to execute a call the model asks for. */
1842
- toolsRecord: Record<string, unknown>;
1842
+ toolsRecord: Record<string, Tool>;
1843
+ /**
1844
+ * Builds the v3 declaration for one tool. When set, every step declares any
1845
+ * tool that joined `toolsRecord` mid-turn (discovery's search_tools loads
1846
+ * tools into it), so the model can call what it just loaded.
1847
+ */
1848
+ declareTool?: (name: string, tool: Tool) => Record<string, unknown>;
1843
1849
  /** The turn's resolved tool choice, applied per step via `resolveStepToolChoice`. */
1844
1850
  toolChoice?: unknown;
1845
1851
  /** Leading steps a forced `toolChoice` stays in force (default 1). */
@@ -35,8 +35,12 @@ export type ResolvedToolPolicy = {
35
35
  includeBound?: string[];
36
36
  /** Denylist of tool-name patterns (exact names or `*` globs), applied after include. */
37
37
  exclude: string[];
38
- /** Defer external MCP tool schemas behind the search_tools meta-tool. */
38
+ /** Defer tool schemas behind the search_tools meta-tool. */
39
39
  discovery: boolean;
40
+ /** Which tools discovery may defer. Absent means "external". */
41
+ discoveryScope?: "external" | "all";
42
+ /** Tool-name patterns (exact names or `*` globs) discovery never defers. Absent means none. */
43
+ discoveryAlwaysVisible?: string[];
40
44
  /** Which option/config sources contributed to this policy (telemetry/debugging). */
41
45
  sources: string[];
42
46
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "13.5.1",
3
+ "version": "13.6.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "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/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {