@juspay/neurolink 12.47.13 → 12.48.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
  3. package/dist/browser/neurolink.min.js +578 -565
  4. package/dist/cli/factories/commandFactory.d.ts +7 -0
  5. package/dist/cli/factories/commandFactory.js +26 -0
  6. package/dist/cli/loop/optionsSchema.js +9 -0
  7. package/dist/context/budgetChecker.js +54 -2
  8. package/dist/context/contextDecision.js +2 -0
  9. package/dist/context/toolPairRepair.d.ts +12 -0
  10. package/dist/context/toolPairRepair.js +29 -3
  11. package/dist/core/baseProvider.d.ts +92 -3
  12. package/dist/core/baseProvider.js +254 -10
  13. package/dist/core/conversationMemoryManager.js +17 -2
  14. package/dist/core/loopEngine.js +1 -1
  15. package/dist/core/modules/MessageBuilder.js +11 -3
  16. package/dist/core/modules/TelemetryHandler.d.ts +2 -2
  17. package/dist/core/modules/ToolsManager.d.ts +35 -0
  18. package/dist/core/modules/ToolsManager.js +159 -55
  19. package/dist/core/nativeGenerateLoop.d.ts +58 -1
  20. package/dist/core/nativeGenerateLoop.js +277 -3
  21. package/dist/core/redisConversationMemoryManager.js +10 -3
  22. package/dist/core/resolveRequestKind.d.ts +24 -4
  23. package/dist/core/resolveRequestKind.js +31 -4
  24. package/dist/core/toolExecutionRecorder.d.ts +6 -0
  25. package/dist/core/toolExecutionRecorder.js +24 -2
  26. package/dist/core/toolRoutingDecision.js +1 -0
  27. package/dist/factories/mediaHandlerCatalog.js +5 -0
  28. package/dist/factories/providerDescriptors.js +15 -0
  29. package/dist/files/fileTools.d.ts +1 -1
  30. package/dist/index.d.ts +5 -2
  31. package/dist/index.js +9 -2
  32. package/dist/memory/condensationPrompt.d.ts +8 -0
  33. package/dist/memory/condensationPrompt.js +29 -0
  34. package/dist/memory/hippocampusInitializer.d.ts +14 -2
  35. package/dist/memory/hippocampusInitializer.js +64 -3
  36. package/dist/neurolink.d.ts +252 -3
  37. package/dist/neurolink.js +898 -43
  38. package/dist/providers/amazonBedrock/client.js +6 -0
  39. package/dist/providers/amazonSagemaker.js +14 -1
  40. package/dist/providers/anthropic/client.js +148 -19
  41. package/dist/providers/anthropic/loopAdapter.js +2 -2
  42. package/dist/providers/googleAiStudio/client.js +29 -14
  43. package/dist/providers/openaiChatCompletionsBase.js +103 -23
  44. package/dist/providers/systemOneDecision.d.ts +6 -0
  45. package/dist/providers/systemOneDecision.js +14 -63
  46. package/dist/providers/typesafe.d.ts +6 -0
  47. package/dist/providers/typesafe.js +6 -0
  48. package/dist/rag/retrieval/searchDecision.js +1 -0
  49. package/dist/routing/classifierRouter.js +6 -5
  50. package/dist/routing/classifierStrategies.js +8 -1
  51. package/dist/types/classifierRouter.d.ts +16 -3
  52. package/dist/types/common.d.ts +2 -0
  53. package/dist/types/config.d.ts +9 -0
  54. package/dist/types/context.d.ts +8 -1
  55. package/dist/types/conversation.d.ts +87 -0
  56. package/dist/types/decision.d.ts +210 -1
  57. package/dist/types/decision.js +9 -1
  58. package/dist/types/dispatch.d.ts +11 -4
  59. package/dist/types/generate.d.ts +118 -54
  60. package/dist/types/loopEngine.d.ts +7 -2
  61. package/dist/types/memory.d.ts +177 -9
  62. package/dist/types/openaiCompatible.d.ts +11 -3
  63. package/dist/types/stream.d.ts +13 -16
  64. package/dist/types/stt.d.ts +58 -1
  65. package/dist/types/tools.d.ts +14 -0
  66. package/dist/types/tools.js +8 -0
  67. package/dist/types/tts.d.ts +108 -0
  68. package/dist/types/voice.d.ts +41 -4
  69. package/dist/utils/decisionAnswers.d.ts +31 -1
  70. package/dist/utils/decisionAnswers.js +114 -0
  71. package/dist/utils/decisionLimits.d.ts +39 -0
  72. package/dist/utils/decisionLimits.js +116 -0
  73. package/dist/utils/logger.d.ts +7 -0
  74. package/dist/utils/logger.js +9 -0
  75. package/dist/utils/messageBuilder.d.ts +45 -1
  76. package/dist/utils/messageBuilder.js +393 -16
  77. package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
  78. package/dist/utils/multimodalOptionsBuilder.js +1 -0
  79. package/dist/utils/speechText.d.ts +41 -0
  80. package/dist/utils/speechText.js +364 -0
  81. package/dist/utils/toolResultStatus.d.ts +26 -0
  82. package/dist/utils/toolResultStatus.js +73 -0
  83. package/dist/utils/ttsProcessor.d.ts +29 -0
  84. package/dist/utils/ttsProcessor.js +84 -2
  85. package/dist/voice/index.d.ts +1 -0
  86. package/dist/voice/index.js +3 -0
  87. package/dist/voice/providers/ElevenLabsSTT.d.ts +42 -0
  88. package/dist/voice/providers/ElevenLabsSTT.js +351 -0
  89. package/dist/voice/providers/ElevenLabsTTS.d.ts +81 -1
  90. package/dist/voice/providers/ElevenLabsTTS.js +415 -74
  91. package/docs-site/static/search-index.json +48 -33
  92. package/package.json +5 -2
  93. package/dist/utils/toolEndEmitter.d.ts +0 -25
  94. package/dist/utils/toolEndEmitter.js +0 -64
@@ -33,9 +33,8 @@ import { redactUrlCredentials } from "../utils/logSanitize.js";
33
33
  import { NoOutputGeneratedError } from "../utils/generationErrors.js";
34
34
  import { buildNoOutputSentinel, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
35
35
  import { composeAbortSignalsScoped, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../utils/timeout.js";
36
- import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
37
36
  import { resolveRequestKind } from "../core/resolveRequestKind.js";
38
- import { appendJsonSchemaInstruction, hasNativeDoGenerate, runNativeGenerateLoop, } from "../core/nativeGenerateLoop.js";
37
+ import { appendJsonSchemaInstruction, hasNativeDoGenerate, resolveStepToolChoice, runNativeGenerateLoop, toPrepareStepRecord, } from "../core/nativeGenerateLoop.js";
39
38
  import { resolveToolExecutionRecords, toolCallsFromSummaries, } from "../core/toolExecutionRecorder.js";
40
39
  import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
41
40
  import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "../utils/json/coerce.js";
@@ -908,6 +907,11 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
908
907
  }
909
908
  const doGenerate = model.doGenerate.bind(model);
910
909
  const shouldUseTools = !options.disableTools && this.supportsTools();
910
+ // `options.tools` arrives from BaseProvider's merge (getToolsForStream)
911
+ // already event-wrapped, recorder-wrapped and discovery-partitioned. It
912
+ // must be used AS IS: copying it would drop the discovery record's null
913
+ // prototype and non-enumerable resolver, and tools search_tools hydrates
914
+ // later land only in the original object.
911
915
  const toolsRecord = shouldUseTools
912
916
  ? options.tools || (await this.getAllTools())
913
917
  : {};
@@ -979,6 +983,11 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
979
983
  ...(withTools && hasTools && options.toolChoice
980
984
  ? { toolChoice: resolveToolChoice(options, toolsRecord, true) }
981
985
  : {}),
986
+ ...(options.toolChoiceSteps !== undefined
987
+ ? { toolChoiceSteps: options.toolChoiceSteps }
988
+ : {}),
989
+ ...(options.prepareStep ? { prepareStep: options.prepareStep } : {}),
990
+ modelId,
982
991
  // The per-call `timeout` keeps its per-MODEL-CALL meaning once
983
992
  // `turnTimeoutMs` owns the whole-turn deadline, and it reaches the
984
993
  // model layer only through this channel. Without it each step fell
@@ -1008,6 +1017,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1008
1017
  runStep: (call) => withProviderRetry(call, trace.getActiveSpan() ?? undefined, `${this.providerName} generate`, undefined, options.abortSignal).catch((err) => {
1009
1018
  throw this.handleProviderError(err);
1010
1019
  }),
1020
+ onRejectedToolCall: (name, error, id) => this.emitRejectedToolCall(name, error, id),
1011
1021
  }, toolExecutionSummaries);
1012
1022
  };
1013
1023
  // Structured output rides `response_format` first. When a vendor rejects
@@ -1138,7 +1148,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1138
1148
  toolExecutions: resolveToolExecutionRecords(options, transformToolExecutions(toolExecutionSummaries)),
1139
1149
  enhancedWithTools: toolsUsed.length > 0,
1140
1150
  };
1141
- return this.finalizeNativeGenerate(enhanced, options, startTime);
1151
+ return this.finalizeNativeGenerate(enhanced, options, startTime, toolExecutionSummaries);
1142
1152
  }
1143
1153
  /**
1144
1154
  * Recover structured output on a turn where tools forced `response_format`
@@ -1454,6 +1464,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1454
1464
  doGenerate,
1455
1465
  conversation: conv,
1456
1466
  toolsRecord: {},
1467
+ modelId,
1457
1468
  ...(format ? { responseFormat: format } : {}),
1458
1469
  maxSteps: 1,
1459
1470
  ...(options.maxTokens
@@ -1575,7 +1586,10 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1575
1586
  let toolsRecord;
1576
1587
  let wireNameMaps;
1577
1588
  let openAITools;
1578
- let openAIToolChoice;
1589
+ // The turn's tool choice in NeuroLink shape; translated to the wire per
1590
+ // step (see stepToolChoice below) so a forced choice can lapse after
1591
+ // `toolChoiceSteps` instead of compelling a tool call on every step.
1592
+ let turnToolChoice;
1579
1593
  // The prompt is kept in its pre-wire shape. Model middleware transforms
1580
1594
  // `params.prompt`, and the conversion to the chat-completions wire format
1581
1595
  // has to happen AFTER that or the transform would be discarded.
@@ -1583,6 +1597,8 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1583
1597
  try {
1584
1598
  modelId = await this.resolveModelName();
1585
1599
  const shouldUseTools = !options.disableTools && this.supportsTools();
1600
+ // Same identity rule as executeNativeGenerate: the merged record is
1601
+ // the discovery hot record and is never copied here.
1586
1602
  toolsRecord = shouldUseTools
1587
1603
  ? options.tools || (await this.getAllTools())
1588
1604
  : {};
@@ -1594,7 +1610,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1594
1610
  openAITools = shouldUseTools
1595
1611
  ? buildToolsForOpenAI(toolsRecord, wireNameMaps?.toWire)
1596
1612
  : undefined;
1597
- openAIToolChoice = mapNeuroLinkToolChoice(resolveToolChoice(options, toolsRecord, shouldUseTools), wireNameMaps?.toWire);
1613
+ turnToolChoice = resolveToolChoice(options, toolsRecord, shouldUseTools);
1598
1614
  promptMessages = (await this.buildMessagesForStream(options));
1599
1615
  }
1600
1616
  catch (setupErr) {
@@ -1621,7 +1637,6 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1621
1637
  const url = this.getChatCompletionsURL(modelId);
1622
1638
  const fetchImpl = createProxyFetch();
1623
1639
  const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
1624
- const emitter = this.neurolink?.getEventEmitter();
1625
1640
  const toolsUsed = [];
1626
1641
  const toolExecutionSummaries = [];
1627
1642
  const { usagePromise, finishPromise, resolveUsage, resolveFinish } = createDeferredAnalytics();
@@ -1715,10 +1730,22 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1715
1730
  options: sampled,
1716
1731
  conversation,
1717
1732
  openAITools,
1718
- openAIToolChoice,
1733
+ // No declared tools → no tool_choice, and the hook is not consulted:
1734
+ // a forced choice on a tool-less request is a 400.
1735
+ stepToolChoice: async (step, steps) => openAITools && openAITools.length > 0
1736
+ ? mapNeuroLinkToolChoice(await resolveStepToolChoice({
1737
+ base: turnToolChoice,
1738
+ step,
1739
+ toolChoiceSteps: options.toolChoiceSteps,
1740
+ prepareStep: options.prepareStep,
1741
+ steps,
1742
+ maxSteps,
1743
+ model: modelId,
1744
+ ...(abortSignal ? { abortSignal } : {}),
1745
+ }), wireNameMaps?.toWire)
1746
+ : undefined,
1719
1747
  toolsRecord,
1720
1748
  toolNameFromWire: wireNameMaps?.fromWire,
1721
- emitter,
1722
1749
  toolsUsed,
1723
1750
  toolExecutionSummaries,
1724
1751
  pushChunk: channel.push,
@@ -2012,7 +2039,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2012
2039
  return result;
2013
2040
  }
2014
2041
  async runStreamLoop(args) {
2015
- const { maxSteps, modelId, url, fetchImpl, abortSignal, options, conversation, openAITools, openAIToolChoice, toolsRecord, toolNameFromWire, emitter, toolsUsed, toolExecutionSummaries, pushChunk, closeChannel, resolveUsage, resolveFinish, responseFormat, onModelObserved, } = args;
2042
+ const { maxSteps, modelId, url, fetchImpl, abortSignal, options, conversation, openAITools, stepToolChoice, toolsRecord, toolNameFromWire, toolsUsed, toolExecutionSummaries, pushChunk, closeChannel, resolveUsage, resolveFinish, responseFormat, onModelObserved, } = args;
2016
2043
  // Hoisted above the try so the catch can resolve the usage accumulated
2017
2044
  // by steps that completed BEFORE the failure — those steps were billed.
2018
2045
  let stepFinish = null;
@@ -2049,7 +2076,10 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2049
2076
  // two different payloads would be meaningless.
2050
2077
  let lastObservedPromptTokens;
2051
2078
  let lastSentEstimate;
2079
+ // Completed-step records handed to a caller's prepareStep hook.
2080
+ const stepRecords = [];
2052
2081
  for (let step = 0; step < maxSteps; step++) {
2082
+ const openAIToolChoice = await stepToolChoice(step, stepRecords);
2053
2083
  // Mid-turn discovery sync: search_tools (tools.discovery) hydrates
2054
2084
  // new tools into toolsRecord between steps. Dispatch already re-reads
2055
2085
  // the record — this block makes the request DECLARE them so the model
@@ -2137,11 +2167,54 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2137
2167
  conversation,
2138
2168
  toolsRecord,
2139
2169
  toolNameFromWire: effectiveToolNameFromWire,
2140
- emitter,
2141
2170
  toolsUsed,
2142
2171
  toolExecutionSummaries,
2143
2172
  options,
2173
+ stepIndex: step,
2144
2174
  });
2175
+ const executed = toolExecutionSummaries.slice(-stepResult.toolCalls.size);
2176
+ stepRecords.push(toPrepareStepRecord({
2177
+ stepNumber: step,
2178
+ content: [
2179
+ ...(stepResult.text
2180
+ ? [{ type: "text", text: stepResult.text }]
2181
+ : []),
2182
+ ...executed.map((s) => ({
2183
+ type: "tool-call",
2184
+ toolCallId: s.toolCallId,
2185
+ toolName: s.toolName,
2186
+ input: s.input,
2187
+ })),
2188
+ ],
2189
+ text: stepResult.text,
2190
+ toolCalls: executed.map((s) => ({
2191
+ toolName: s.toolName,
2192
+ toolCallId: s.toolCallId,
2193
+ input: s.input,
2194
+ })),
2195
+ toolResults: executed.map((s) => ({
2196
+ toolName: s.toolName,
2197
+ toolCallId: s.toolCallId,
2198
+ output: s.output,
2199
+ })),
2200
+ finishReason: "tool-calls",
2201
+ // prompt_tokens already includes cached_tokens; subtract it so
2202
+ // this agrees with the generate loop's toDeferredUsage, which
2203
+ // reports the disjoint noCache figure for the same field —
2204
+ // otherwise a cached step reports cached tokens twice (once
2205
+ // here, once in cacheReadTokens) and a prepareStep hook that
2206
+ // budgets on steps[].usage sees different numbers from
2207
+ // stream() and generate() for the same step.
2208
+ inputTokens: Math.max(0, (stepResult.usage?.prompt_tokens ?? 0) -
2209
+ (stepResult.usage?.prompt_tokens_details?.cached_tokens ?? 0)),
2210
+ outputTokens: stepResult.usage?.completion_tokens ?? 0,
2211
+ ...(stepResult.usage?.prompt_tokens_details?.cached_tokens !==
2212
+ undefined
2213
+ ? {
2214
+ cacheReadTokens: Math.min(stepResult.usage.prompt_tokens_details.cached_tokens, stepResult.usage.prompt_tokens ?? 0),
2215
+ }
2216
+ : {}),
2217
+ }));
2145
2218
  }
2146
2219
  resolveUsage(toDeferredUsage());
2147
2220
  resolveFinish(stepFinish ?? "stop");
@@ -2258,7 +2331,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2258
2331
  });
2259
2332
  }
2260
2333
  async executeToolBatch(args) {
2261
- const { stepResult, conversation, toolsRecord, toolNameFromWire, emitter, toolsUsed, toolExecutionSummaries, options, } = args;
2334
+ const { stepResult, conversation, toolsRecord, toolNameFromWire, toolsUsed, toolExecutionSummaries, options, stepIndex, } = args;
2262
2335
  const toolCallsForMessage = [];
2263
2336
  for (const [, t] of stepResult.toolCalls) {
2264
2337
  toolCallsForMessage.push({
@@ -2295,14 +2368,14 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2295
2368
  // loaded via search_tools — that's a real tool, not a hallucination.
2296
2369
  const toolDef = toolsRecord[registryName] ??
2297
2370
  resolveDeferredTool(toolsRecord, registryName);
2298
- emitter?.emit("tool:start", {
2299
- toolName: registryName,
2300
- toolCallId: t.id,
2301
- input,
2302
- });
2371
+ // No `tool:start` here either: registered and built-in tools emit their
2372
+ // own start/end pair from the executor that runs them, and this raw
2373
+ // emission made every one of them count twice.
2303
2374
  if (!toolDef || typeof toolDef.execute !== "function") {
2304
2375
  errorMsg = `Tool '${registryName}' is not registered.`;
2305
2376
  output = { error: errorMsg };
2377
+ // Rejected before any execute ran, so no executor will report it.
2378
+ this.emitRejectedToolCall(registryName, errorMsg, t.id);
2306
2379
  }
2307
2380
  else {
2308
2381
  try {
@@ -2323,6 +2396,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2323
2396
  ...(errorMsg ? { error: errorMsg } : {}),
2324
2397
  startTime: startedAt,
2325
2398
  endTime: endedAt,
2399
+ stepIndex,
2326
2400
  });
2327
2401
  conversation.push({
2328
2402
  role: "tool",
@@ -2331,21 +2405,27 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2331
2405
  });
2332
2406
  }
2333
2407
  const justExecuted = toolExecutionSummaries.slice(-stepResult.toolCalls.size);
2334
- emitToolEndFromStepFinish(emitter, justExecuted.map((s) => ({
2335
- toolName: s.toolName,
2336
- output: s.output,
2337
- ...(s.error ? { error: s.error } : {}),
2338
- })));
2408
+ // No `tool:end` here: the executor already emitted start/end with the
2409
+ // real duration, and a second emission per result doubled every
2410
+ // consumer's count.
2339
2411
  try {
2412
+ // `args`, not `input`: both stores read `args`, so the previous
2413
+ // `input:` key persisted every tool call with empty arguments.
2340
2414
  await this.handleToolExecutionStorage(justExecuted.map((s) => ({
2341
2415
  toolCallId: s.toolCallId,
2342
2416
  toolName: s.toolName,
2343
- input: s.input,
2344
- output: s.output,
2417
+ args: s.input !== null &&
2418
+ typeof s.input === "object" &&
2419
+ !Array.isArray(s.input)
2420
+ ? s.input
2421
+ : {},
2422
+ ...(s.stepIndex !== undefined ? { stepIndex: s.stepIndex } : {}),
2345
2423
  })), justExecuted.map((s) => ({
2346
2424
  toolCallId: s.toolCallId,
2347
2425
  toolName: s.toolName,
2348
2426
  output: s.output,
2427
+ ...(s.error ? { error: s.error } : {}),
2428
+ ...(s.stepIndex !== undefined ? { stepIndex: s.stepIndex } : {}),
2349
2429
  })), options, new Date());
2350
2430
  }
2351
2431
  catch (err) {
@@ -133,6 +133,12 @@ export declare abstract class SystemOneDecisionProvider extends BaseProvider {
133
133
  * Refuse a request the model cannot read in full. An encoder cuts the state
134
134
  * off past its window without saying so, so without this a decision would
135
135
  * be made on input the model never saw — and reported as if it had.
136
+ *
137
+ * Advisory limits (`decisionLimits.advisory`) are skipped: they publish a
138
+ * server's own ceiling for `NeuroLink.decisionLimits()` to report, and the
139
+ * server enforces them itself. The estimator and the flattening are the
140
+ * same functions that method uses, so a host that sizes a state against the
141
+ * reading measures exactly what is checked here.
136
142
  */
137
143
  private assertWithinDecisionLimits;
138
144
  /**
@@ -6,7 +6,7 @@ import { ProviderError } from "../types/index.js";
6
6
  import { prepareDecisionMedia } from "../utils/decisionMedia.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { redactUrlsInText } from "../utils/logSanitize.js";
9
- import { CHARS_PER_TOKEN, estimateTokens, serializeForEstimate, } from "../utils/tokenEstimation.js";
9
+ import { estimateDecisionStateTokens, resolveDecisionLimitsReading, } from "../utils/decisionLimits.js";
10
10
  /**
11
11
  * Generous enough for a cold start (measured at 2.0–2.7s after idle on Jev)
12
12
  * while still bounded. Every internal caller is fail-open, so this only bites
@@ -171,59 +171,6 @@ const sleep = (ms, signal) => new Promise((resolve, reject) => {
171
171
  }
172
172
  signal?.addEventListener("abort", onAbort, { once: true });
173
173
  });
174
- /**
175
- * `estimateTokens` assumes ~4 characters per token. That holds for English
176
- * and is several times too generous for other scripts on an English
177
- * tokenizer, so when a provider declares a rate, non-ASCII characters are
178
- * counted at it instead. ASCII text is estimated exactly as before.
179
- */
180
- function estimateDecisionStateTokens(text, nonAsciiTokensPerChar, rates = {}) {
181
- const { digit, symbol, astral } = rates;
182
- if (nonAsciiTokensPerChar === undefined &&
183
- digit === undefined &&
184
- symbol === undefined &&
185
- astral === undefined) {
186
- return estimateTokens(text);
187
- }
188
- // Each class of character is counted once, at its own rate. A class with no
189
- // declared rate stays in the ordinary four-characters-a-token estimate (or,
190
- // for non-ASCII, at that estimate's per-character rate), so a provider that
191
- // declares none of the extra rates is estimated exactly as before.
192
- const isDigit = (c) => c >= "0" && c <= "9";
193
- const isSymbol = (c) => /[!-/:-@[-`{-~]/.test(c);
194
- let digits = 0;
195
- let symbols = 0;
196
- let astrals = 0;
197
- let nonAscii = 0;
198
- const rest = [];
199
- for (const c of text) {
200
- const code = c.codePointAt(0) ?? 0;
201
- if (code > 0xffff && astral !== undefined) {
202
- astrals += 1;
203
- }
204
- else if (code > 0x7f) {
205
- nonAscii += 1;
206
- }
207
- else if (digit !== undefined && isDigit(c)) {
208
- digits += 1;
209
- }
210
- else if (symbol !== undefined && isSymbol(c)) {
211
- symbols += 1;
212
- }
213
- else {
214
- rest.push(c);
215
- }
216
- }
217
- // Digits, punctuation and astral characters (emoji) are charged separately
218
- // only when the provider declares a rate for them: a tokenizer that reads each
219
- // digit, and most punctuation, on its own makes a number-heavy or JSON-heavy
220
- // state several times longer than four characters a token suggests.
221
- return (estimateTokens(rest.join("")) +
222
- Math.ceil(digits * (digit ?? 0)) +
223
- Math.ceil(symbols * (symbol ?? 0)) +
224
- Math.ceil(astrals * (astral ?? 0)) +
225
- Math.ceil(nonAscii * (nonAsciiTokensPerChar ?? 1 / CHARS_PER_TOKEN)));
226
- }
227
174
  /**
228
175
  * The shared half of every "System One" decision provider — a model that takes
229
176
  * one `state` plus typed questions and returns one typed answer each, with no
@@ -551,10 +498,19 @@ export class SystemOneDecisionProvider extends BaseProvider {
551
498
  * Refuse a request the model cannot read in full. An encoder cuts the state
552
499
  * off past its window without saying so, so without this a decision would
553
500
  * be made on input the model never saw — and reported as if it had.
501
+ *
502
+ * Advisory limits (`decisionLimits.advisory`) are skipped: they publish a
503
+ * server's own ceiling for `NeuroLink.decisionLimits()` to report, and the
504
+ * server enforces them itself. The estimator and the flattening are the
505
+ * same functions that method uses, so a host that sizes a state against the
506
+ * reading measures exactly what is checked here.
554
507
  */
555
508
  assertWithinDecisionLimits(request, model, questionCount) {
556
- const limits = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.decisionLimits;
557
- if (!limits) {
509
+ const descriptor = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName);
510
+ const limits = descriptor
511
+ ? resolveDecisionLimitsReading(descriptor, model)
512
+ : null;
513
+ if (!limits || !limits.enforcedLocally) {
558
514
  return;
559
515
  }
560
516
  const label = this.vendorLabel();
@@ -566,13 +522,8 @@ export class SystemOneDecisionProvider extends BaseProvider {
566
522
  retryable: false,
567
523
  });
568
524
  }
569
- const modelLimits = limits.models?.[model];
570
- const maxStateTokens = modelLimits?.maxStateTokens ?? limits.maxStateTokens;
571
- const stateTokens = estimateDecisionStateTokens(serializeForEstimate(request.state), modelLimits?.nonAsciiTokensPerChar ?? limits.nonAsciiTokensPerChar, {
572
- digit: limits.digitTokensPerChar,
573
- symbol: limits.symbolTokensPerChar,
574
- astral: limits.astralTokensPerChar,
575
- });
525
+ const { maxStateTokens } = limits;
526
+ const stateTokens = estimateDecisionStateTokens(request.state, limits);
576
527
  if (stateTokens > maxStateTokens) {
577
528
  throw this.decisionError({
578
529
  kind: "max_tokens_exceeded",
@@ -4,6 +4,12 @@ import { SystemOneDecisionProvider } from "./systemOneDecision.js";
4
4
  * The binding limit in practice: `state` plus the SINGLE LONGEST question.
5
5
  * Measured by bisection at single-character resolution — 33,002 reported
6
6
  * input tokens accepted, 33,003 not.
7
+ *
8
+ * The same figure sits on the TypeSafe descriptor as an ADVISORY
9
+ * `decisionLimits.maxStateTokens`, which is what `NeuroLink.decisionLimits()`
10
+ * reads; the server enforces it, NeuroLink only reports it. The descriptor
11
+ * cannot import this constant without a cycle, so the decide suite pins them
12
+ * equal instead.
7
13
  */
8
14
  export declare const TYPESAFE_MAX_STATE_TOKENS = 33000;
9
15
  /**
@@ -27,6 +27,12 @@ const GATEWAY_SPEC_VERSION = "4";
27
27
  * The binding limit in practice: `state` plus the SINGLE LONGEST question.
28
28
  * Measured by bisection at single-character resolution — 33,002 reported
29
29
  * input tokens accepted, 33,003 not.
30
+ *
31
+ * The same figure sits on the TypeSafe descriptor as an ADVISORY
32
+ * `decisionLimits.maxStateTokens`, which is what `NeuroLink.decisionLimits()`
33
+ * reads; the server enforces it, NeuroLink only reports it. The descriptor
34
+ * cannot import this constant without a cycle, so the decide suite pins them
35
+ * equal instead.
30
36
  */
31
37
  export const TYPESAFE_MAX_STATE_TOKENS = 33_000;
32
38
  /**
@@ -75,6 +75,7 @@ export async function decideSearchPlan(query, decide, capabilities) {
75
75
  };
76
76
  }
77
77
  const result = await decide({
78
+ site: "ragPlan",
78
79
  state: { query: query.slice(0, 4000) },
79
80
  // The map is assembled dynamically above; its members are all valid
80
81
  // question shapes, which the cast asserts once here rather than at four
@@ -98,7 +98,7 @@ export class ClassifierRouter {
98
98
  try {
99
99
  // Both the LLM and Jev strategies can pick a model straight out of the
100
100
  // pool — the generic path for custom/registry-less models.
101
- const strategy = this.resolveStrategy();
101
+ const strategy = this.resolveStrategy(input);
102
102
  const usesCandidates = (strategy === "jev" && !!this.deps.decide) ||
103
103
  (strategy === "llm" && !!this.deps.generate);
104
104
  const built = usesCandidates ? this.buildCandidates() : undefined;
@@ -138,7 +138,7 @@ export class ClassifierRouter {
138
138
  * moment a decision provider is configured; without one the behaviour is
139
139
  * exactly what it was before Jev existed.
140
140
  */
141
- resolveStrategy() {
141
+ resolveStrategy(input) {
142
142
  const configured = this.config.classifier ?? "auto";
143
143
  if (configured !== "auto") {
144
144
  return configured;
@@ -146,10 +146,11 @@ export class ClassifierRouter {
146
146
  // A decision provider that is both registered AND configured is the whole
147
147
  // activation condition — this is where "if somebody sets it we start
148
148
  // using it" lives for routing. The caller's check also counts the
149
- // credentials it was constructed with.
149
+ // credentials it was constructed with and the request's own per-call
150
+ // credentials, which the decision call itself will use.
150
151
  const hasProvider = this.deps.hasDecisionProvider
151
- ? this.deps.hasDecisionProvider()
152
- : resolveDefaultDecisionProvider() !== undefined;
152
+ ? this.deps.hasDecisionProvider(input.credentials)
153
+ : resolveDefaultDecisionProvider(input.credentials) !== undefined;
153
154
  return this.deps.decide && hasProvider ? "jev" : "heuristic";
154
155
  }
155
156
  /** Run the chosen strategy; every one falls back to heuristic on failure. */
@@ -297,8 +297,15 @@ export async function classifyJev(input, decide, timeoutMs, candidates, threshol
297
297
  // short conversational one.
298
298
  // Continuity is sent as `session_bound` / `prior_messages`, never as the
299
299
  // raw sessionId: the id is an opaque identifier that tells a third-party
300
- // model nothing, so it stays in-process.
300
+ // model nothing, so it stays in-process — as do the site stamp, the
301
+ // per-call credentials and the ids below, which the caller reads and the
302
+ // wire never sees.
301
303
  const result = await decide({
304
+ site: "routing",
305
+ credentials: input.credentials,
306
+ signal: input.signal,
307
+ sessionId: input.sessionId,
308
+ requestId: input.requestId,
302
309
  state: {
303
310
  request: (input.prompt ?? "").slice(0, 8000),
304
311
  estimated_input_tokens: input.estimatedInputTokens ?? 0,
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { ValidationSchema } from "./aliases.js";
17
17
  import type { DecisionCallerFn } from "./decision.js";
18
+ import type { NeurolinkCredentials } from "./providers.js";
18
19
  /** Coarse difficulty buckets the classifier maps a request into. */
19
20
  export type ClassifierDifficulty = "trivial" | "simple" | "moderate" | "hard" | "expert";
20
21
  /**
@@ -307,6 +308,16 @@ export type ClassifierRouterInput = {
307
308
  sessionBound?: boolean;
308
309
  /** Number of prior conversation messages the caller supplied, when known. */
309
310
  priorMessageCount?: number;
311
+ /**
312
+ * The outer request's per-call credentials, forwarded to the decision call
313
+ * so it reaches the caller's provider account rather than the instance's.
314
+ * Never part of the state sent to the model.
315
+ */
316
+ credentials?: NeurolinkCredentials;
317
+ /** The outer request's abort signal; an abandoned turn abandons its routing call. */
318
+ signal?: AbortSignal;
319
+ /** The outer request's id, for hook and event correlation. Never sent to the model. */
320
+ requestId?: string;
310
321
  };
311
322
  /** Enriched per-model metadata used while ranking pool members. */
312
323
  export type ClassifierModelMeta = {
@@ -359,9 +370,11 @@ export type ClassifierRouterDeps = {
359
370
  decide?: ClassifierDecideFn;
360
371
  /**
361
372
  * Whether a decision provider is configured for this caller, counting the
362
- * credentials it was given as well as the environment. Omit to check the
363
- * environment alone.
373
+ * credentials it was given as well as the environment — and the request's
374
+ * own per-call `credentials`, passed in from `ClassifierRouterInput`, so a
375
+ * caller that holds no decision key itself can still route a request that
376
+ * carries one. Omit to check the environment alone.
364
377
  */
365
- hasDecisionProvider?: () => boolean;
378
+ hasDecisionProvider?: (callCredentials?: NeurolinkCredentials) => boolean;
366
379
  logger?: ClassifierLogger;
367
380
  };
@@ -102,6 +102,8 @@ export type NeuroLinkEvents = {
102
102
  "stream:error": unknown;
103
103
  "generation:start": unknown;
104
104
  "generation:end": unknown;
105
+ "decision:before": unknown;
106
+ "decision:after": unknown;
105
107
  "response:start": unknown;
106
108
  "response:end": unknown;
107
109
  "externalMCP:serverConnected": unknown;
@@ -14,6 +14,7 @@ import type { ModelPoolConfig } from "./modelPool.js";
14
14
  import type { RequestRouter } from "./requestRouter.js";
15
15
  import type { ClassifierRouterConfig } from "./classifierRouter.js";
16
16
  import type { ContextRelevanceOptions } from "./context.js";
17
+ import type { DecisionHooks } from "./decision.js";
17
18
  import type { SkillsConfig } from "./skills.js";
18
19
  import type { KnowledgeGroundingConfig } from "./knowledge.js";
19
20
  /**
@@ -155,6 +156,14 @@ export type NeurolinkConstructorConfig = {
155
156
  * which is the same activation rule every other decision consumer uses.
156
157
  */
157
158
  contextRelevance?: ContextRelevanceOptions;
159
+ /**
160
+ * Ride along on the decision calls NeuroLink already makes for routing,
161
+ * tool routing, compaction and RAG planning: add your own questions to the
162
+ * same round trip and read the answers back. Observe-only with respect to
163
+ * NeuroLink's behaviour, fail-open, and inert until a decision provider is
164
+ * configured. See {@link DecisionHooks}.
165
+ */
166
+ decisionHooks?: DecisionHooks;
158
167
  /**
159
168
  * Native skills: versioned, discoverable instruction packs (SOPs,
160
169
  * playbooks) with progressive disclosure. When enabled, each
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { ExecutionContext } from "../types/tools.js";
6
6
  import type { JsonObject } from "./common.js";
7
- import type { ChatMessage, ConversationMemoryConfig } from "./conversation.js";
7
+ import type { ChatMessage, ConversationMemoryConfig, ToolReplayMode } from "./conversation.js";
8
8
  import type { DecisionCallerFn } from "./decision.js";
9
9
  /**
10
10
  * Base context type for all AI operations
@@ -237,6 +237,13 @@ export type BudgetCheckParams = {
237
237
  }>;
238
238
  /** Compaction trigger threshold (0.0-1.0). Default: 0.80 */
239
239
  compactionThreshold?: number;
240
+ /**
241
+ * How this request replays stored `tool_call` / `tool_result` rows
242
+ * (`replayToolSteps`). When set, tool rows are estimated at the size they
243
+ * reach the model in that mode rather than at stored size; when omitted
244
+ * they count at stored size.
245
+ */
246
+ toolReplayMode?: ToolReplayMode;
240
247
  };
241
248
  /** A file prepared for potential summarization. */
242
249
  export type FileForSummarization = {