@juspay/neurolink 12.47.14 → 12.48.1

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 +2 -2
  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 +2 -1
  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
package/dist/neurolink.js CHANGED
@@ -32,7 +32,9 @@ import { DEFAULT_RECENT_TURNS, KnowledgeGroundingEngine, } from "./knowledge/ind
32
32
  import { AIProviderFactory } from "./core/factory.js";
33
33
  import { describeDecisionProviderKeys, PROVIDER_ALIAS_INDEX, PROVIDER_DESCRIPTORS_BY_NAME, resolveDefaultDecisionProvider, } from "./factories/providerDescriptors.js";
34
34
  import { MAX_PARALLEL_DECISION_BATCHES, mergeDecisionResults, splitDecisionRequest, } from "./utils/decisionBatches.js";
35
- import { resolveRequestKind } from "./core/resolveRequestKind.js";
35
+ import { decisionKey, HOST_DECISION_NAMESPACE, isHostDecisionKey, snapshotDecisionQuestion, stripHostAnswers, } from "./utils/decisionAnswers.js";
36
+ import { resolveDecisionLimitsReading } from "./utils/decisionLimits.js";
37
+ import { isDirectTTSRequest, resolveRequestKind, } from "./core/resolveRequestKind.js";
36
38
  import { createToolEventPayload } from "./core/toolEvents.js";
37
39
  import { ProviderFactory } from "./factories/providerFactory.js";
38
40
  import { ProviderRegistry } from "./factories/providerRegistry.js";
@@ -55,6 +57,7 @@ import { MCPToolRegistry } from "./mcp/toolRegistry.js";
55
57
  // Dynamic argument resolution imports
56
58
  import { resolveDynamicArgument } from "./dynamic/dynamicResolver.js";
57
59
  import { initializeHippocampus } from "./memory/hippocampusInitializer.js";
60
+ import { validateCondensationPrompt } from "./memory/condensationPrompt.js";
58
61
  import { createMemoryRetrievalTools } from "./memory/memoryRetrievalTools.js";
59
62
  import { isSkillVisibleInScope } from "./skills/skillMatcher.js";
60
63
  import { buildSkillActivationMessage } from "./skills/skillSessionTracker.js";
@@ -62,7 +65,7 @@ import { SkillsManager } from "./skills/skillsManager.js";
62
65
  import { createSkillCallTools, createSkillTools } from "./skills/skillTools.js";
63
66
  import { getMetricsAggregator, MetricsAggregator, } from "./observability/metricsAggregator.js";
64
67
  import { AnalyticsService, calculateAdvancedCost, parseAnalyticsQualityScore, } from "./analytics/index.js";
65
- import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, ModelAccessDeniedError, } from "./types/index.js";
68
+ import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, DEFAULT_DECISION_HOOK_TIMEOUT_MS, MAX_DECISION_HOOK_TIMEOUT_MS, ModelAccessDeniedError, } from "./types/index.js";
66
69
  import { resolveTerminalAgentMode } from "./agent/prompts/terminalAgentPrompt.js";
67
70
  import { SpanSerializer } from "./observability/utils/spanSerializer.js";
68
71
  import { withSpan } from "./telemetry/withSpan.js";
@@ -192,6 +195,12 @@ const isNonRetryableProviderError = sharedIsNonRetryableProviderError;
192
195
  * ModelPool catch sites; every other path keeps the general contract.
193
196
  */
194
197
  const isNonRetryableForPool = sharedIsNonRetryableForPool;
198
+ /**
199
+ * Ceiling on one background memory write (`Promise.all` of its `add()`
200
+ * calls), and therefore on how long `shutdown()` / `dispose()` wait for the
201
+ * writes already scheduled before releasing the condenser.
202
+ */
203
+ const MEMORY_WRITE_TIMEOUT_MS = 30_000;
195
204
  /**
196
205
  * NeuroLink - Universal AI Development Platform
197
206
  *
@@ -517,6 +526,9 @@ export class NeuroLink {
517
526
  // Undefined = the stage's own defaults; the stage itself is gated on a
518
527
  // decision provider being configured, not on this.
519
528
  contextRelevanceOptions;
529
+ // Host hooks riding along on every built-in decide site call. Undefined =
530
+ // no hooks, and the site calls behave exactly as they did without them.
531
+ decisionHooks;
520
532
  /**
521
533
  * Merge instance-level credentials with per-call credentials.
522
534
  *
@@ -580,6 +592,38 @@ export class NeuroLink {
580
592
  // Memory instance and config
581
593
  memoryInstance;
582
594
  memorySDKConfig;
595
+ /**
596
+ * Set when the instance-level condensation prompt failed validation at
597
+ * construction (see initializeConversationMemory). Memory stays off for
598
+ * the instance's lifetime; the reason is what the warning already said.
599
+ */
600
+ memoryDisabledReason;
601
+ /**
602
+ * Dedicated child instance Hippocampus condenses through. Built on first
603
+ * condensation, never at construction — so a read-only host, or one whose
604
+ * memory never activates, never pays for a second NeuroLink.
605
+ */
606
+ memoryCondenser;
607
+ /**
608
+ * Set by `releaseMemoryCondenser()`, i.e. by `shutdown()` / `dispose()`,
609
+ * once the writes already scheduled have been drained. A memory write
610
+ * scheduled past that point must skip, and the condenser must never be
611
+ * rebuilt: a child resurrected after release would outlive its host,
612
+ * unreleased, and — after `dispose()` wiped the credentials — condense
613
+ * with none.
614
+ */
615
+ memoryCondenserReleased = false;
616
+ /**
617
+ * Memory writes scheduled and not yet settled. `releaseMemoryCondenser()`
618
+ * awaits them (bounded) before it releases anything, so
619
+ * `await generate(); await shutdown();` — a serverless handler, a
620
+ * per-request instance, a one-shot CLI run — still stores its last turn.
621
+ */
622
+ pendingMemoryWrites = new Set();
623
+ /** Prompt templates already warned about, so a per-call template warns once. */
624
+ warnedCondensationPrompts = new Set();
625
+ /** `decisionHooks.hookTimeoutMs` once validated; see `decisionHookTimeout`. */
626
+ decisionHookTimeoutMs;
583
627
  // Skills subsystem — lazily initialized manager + instance config.
584
628
  // `undefined` = not yet attempted, `null` = init failed (stay disabled).
585
629
  skillsManagerInstance;
@@ -811,12 +855,157 @@ export class NeuroLink {
811
855
  }
812
856
  initializeMemoryConfig() {
813
857
  const memory = this.conversationMemoryConfig?.conversationMemory?.memory;
814
- if (!memory?.enabled) {
858
+ if (!memory?.enabled || this.memoryDisabledReason) {
815
859
  return false;
816
860
  }
817
- this.memorySDKConfig = memory;
861
+ // Only the HippocampusConfig part reaches the Hippocampus constructor;
862
+ // `enabled`, `client`, `strictPrompt` and the write hooks are NeuroLink's.
863
+ const { enabled: _enabled, client: _client, strictPrompt: _strictPrompt, shouldWrite: _shouldWrite, onBeforeStore: _onBeforeStore, prompt, ...sdkConfig } = memory;
864
+ this.memorySDKConfig = {
865
+ ...sdkConfig,
866
+ // A blank template counts as unset here (validateInstanceCondensationPrompt
867
+ // skips it), so it must not reach Hippocampus either: it resolves
868
+ // `config.prompt || HC_CONDENSATION_PROMPT || built-in`, and " " is
869
+ // truthy — it would condense with a template that has no placeholders.
870
+ ...(prompt?.trim() ? { prompt } : {}),
871
+ neurolink: {
872
+ ...(memory.neurolink ?? {}),
873
+ credentials: memory.neurolink?.credentials ?? this.credentials,
874
+ instance: memory.neurolink?.instance ?? this.lazyMemoryCondenserInstance(),
875
+ },
876
+ };
818
877
  return true;
819
878
  }
879
+ /**
880
+ * A `generate()`-only handle whose backing NeuroLink is constructed on the
881
+ * first condensation call, not when memory is configured or first read.
882
+ */
883
+ lazyMemoryCondenserInstance() {
884
+ return {
885
+ generate: async (options) => {
886
+ const condenser = this.getMemoryCondenser();
887
+ if (!condenser) {
888
+ // Hippocampus swallows a condensation error and keeps the old
889
+ // memory, which is exactly the right outcome for a write that
890
+ // outlived its host: skipped, never condensed with no credentials.
891
+ logger.debug("[NeuroLink] Memory condensation skipped: the host instance has been shut down");
892
+ throw new Error("Memory condenser released: the host NeuroLink instance has been shut down");
893
+ }
894
+ return condenser.generate(options);
895
+ },
896
+ };
897
+ }
898
+ /**
899
+ * Release the lazily created condenser with its host. Its own
900
+ * `shutdown()` is a no-op when condensation never ran; when it did, the
901
+ * child's memory is disabled and it holds no MCP servers, so this is
902
+ * cheap — the point is that the host's lifecycle owns the child's.
903
+ *
904
+ * The writes already scheduled are drained FIRST, bounded by the write
905
+ * timeout: a turn's memory write is deferred to `setImmediate`, so
906
+ * `await generate(); await shutdown();` would otherwise never store the
907
+ * turn. Only then is the host marked released, so a write scheduled after
908
+ * that point skips instead of rebuilding the child.
909
+ */
910
+ async releaseMemoryCondenser() {
911
+ await this.drainPendingMemoryWrites();
912
+ this.memoryCondenserReleased = true;
913
+ const condenser = this.memoryCondenser;
914
+ if (!condenser) {
915
+ return;
916
+ }
917
+ this.memoryCondenser = undefined;
918
+ await condenser.shutdown();
919
+ }
920
+ /**
921
+ * Wait for every scheduled memory write to settle, including writes
922
+ * scheduled while waiting, within one `MEMORY_WRITE_TIMEOUT_MS` budget.
923
+ * A write that outlives the budget is left in flight and warned about —
924
+ * it will skip or fail on its own once the condenser is gone — rather
925
+ * than hold shutdown open indefinitely.
926
+ */
927
+ async drainPendingMemoryWrites() {
928
+ if (this.pendingMemoryWrites.size === 0) {
929
+ return;
930
+ }
931
+ const deadline = Date.now() + MEMORY_WRITE_TIMEOUT_MS;
932
+ while (this.pendingMemoryWrites.size > 0) {
933
+ const remainingMs = deadline - Date.now();
934
+ const inFlight = [...this.pendingMemoryWrites];
935
+ if (remainingMs <= 0) {
936
+ this.warnMemoryWritesLeftInFlight(inFlight.length);
937
+ return;
938
+ }
939
+ try {
940
+ await withTimeout(Promise.allSettled(inFlight), remainingMs, new Error(`Memory writes still in flight after ${MEMORY_WRITE_TIMEOUT_MS}ms`));
941
+ }
942
+ catch {
943
+ this.warnMemoryWritesLeftInFlight(this.pendingMemoryWrites.size);
944
+ return;
945
+ }
946
+ }
947
+ }
948
+ warnMemoryWritesLeftInFlight(count) {
949
+ logger.warn(`[NeuroLink] Shutdown proceeded with ${count} memory write(s) still in flight after ${MEMORY_WRITE_TIMEOUT_MS}ms; the last turn may not be stored`, { pendingWrites: count });
950
+ }
951
+ /**
952
+ * The child instance Hippocampus condenses through, or null once the host
953
+ * has been released.
954
+ *
955
+ * Deliberately NOT `this`: routing condensation through the parent would
956
+ * run its MCP init, skills and middleware, emit `generation:*` on the
957
+ * parent emitter and add the condensation call's cost to
958
+ * `_sessionCostUsd`. The child inherits `credentials` (or the explicit
959
+ * `memory.neurolink.credentials`) so condensation no longer depends on
960
+ * provider keys in `process.env`, has memory disabled so it can never
961
+ * recurse into a memory write of its own, and — like createWorkerInstance —
962
+ * joins the host tracer without a second Langfuse exporter.
963
+ */
964
+ getMemoryCondenser() {
965
+ if (this.memoryCondenser) {
966
+ return this.memoryCondenser;
967
+ }
968
+ if (this.memoryCondenserReleased) {
969
+ return null;
970
+ }
971
+ // Constructing an instance rebinds the process-global logger sink to the
972
+ // new instance's emitter. That sink is whatever the process last set —
973
+ // not necessarily this host's emitter (another instance may own a
974
+ // log-event bridge) — so it is captured and put back as it was, rather
975
+ // than pointed at the host.
976
+ const previousSink = logger.getEventEmitter();
977
+ const credentials = this.conversationMemoryConfig?.conversationMemory?.memory?.neurolink
978
+ ?.credentials ?? this.credentials;
979
+ const hostLangfuse = this.observabilityConfig?.langfuse;
980
+ this.memoryCondenser = new NeuroLink({
981
+ ...(credentials ? { credentials } : {}),
982
+ conversationMemory: { enabled: false },
983
+ enableOrchestration: false,
984
+ ...(this.observabilityConfig
985
+ ? {
986
+ observability: {
987
+ ...this.observabilityConfig,
988
+ ...(hostLangfuse
989
+ ? {
990
+ langfuse: {
991
+ ...hostLangfuse,
992
+ autoDetectExternalProvider: true,
993
+ skipLangfuseSpanProcessor: true,
994
+ },
995
+ }
996
+ : {}),
997
+ },
998
+ }
999
+ : {}),
1000
+ });
1001
+ if (previousSink) {
1002
+ logger.setEventEmitter(previousSink);
1003
+ }
1004
+ else {
1005
+ logger.clearEventEmitter(this.memoryCondenser.emitter);
1006
+ }
1007
+ return this.memoryCondenser;
1008
+ }
820
1009
  /**
821
1010
  * Lazy initialization for memory — called during generate/stream.
822
1011
  */
@@ -832,7 +1021,7 @@ export class NeuroLink {
832
1021
  this.memoryInstance = null;
833
1022
  return null;
834
1023
  }
835
- this.memoryInstance = initializeHippocampus(this.memorySDKConfig);
1024
+ this.memoryInstance = initializeHippocampus(this.memorySDKConfig, this.conversationMemoryConfig?.conversationMemory?.memory?.client);
836
1025
  return this.memoryInstance;
837
1026
  }
838
1027
  /**
@@ -990,6 +1179,7 @@ export class NeuroLink {
990
1179
  // reading config at each call site) keeps compactor construction uniform
991
1180
  // across the five places that build one.
992
1181
  this.contextRelevanceOptions = config?.contextRelevance;
1182
+ this.decisionHooks = config?.decisionHooks;
993
1183
  // ClassifierRouter: opt-in. The LLM strategy reuses this instance's
994
1184
  // generate() (marked so it never recursively re-routes). Fails open.
995
1185
  this.classifierRouter = config?.classifierRouter?.enabled
@@ -997,12 +1187,18 @@ export class NeuroLink {
997
1187
  generate: (genOptions) => this.preservingTurnState(() => this.generate({
998
1188
  ...genOptions,
999
1189
  })),
1000
- // Fail-open by construction: tryDecide returns null rather than
1190
+ // Fail-open by construction: siteDecide returns null rather than
1001
1191
  // throwing, so an absent or broken decision provider leaves routing
1002
- // exactly as it was.
1003
- decide: (decideOptions) => this.tryDecide(decideOptions),
1004
- // Read lazily: `this.credentials` is assigned later in the constructor.
1005
- hasDecisionProvider: () => resolveDefaultDecisionProvider(this.credentials) !== undefined,
1192
+ // exactly as it was. The per-call credentials and ids arrive on the
1193
+ // options themselves — classifyJev forwards them from the router
1194
+ // input, since this caller is fixed at construction.
1195
+ decide: (decideOptions) => this.siteDecide(decideOptions),
1196
+ // Read lazily: `this.credentials` is assigned later in the
1197
+ // constructor. Counts the request's per-call credentials too, so a
1198
+ // host that holds no decision key itself but passes a tenant's on
1199
+ // each call gets the decision strategy, as the other sites do.
1200
+ hasDecisionProvider: (callCredentials) => this.resolveDecisionProviderName(undefined, callCredentials) !==
1201
+ undefined,
1006
1202
  logger: {
1007
1203
  debug: (message, meta) => logger.debug(message, meta),
1008
1204
  warn: (message, meta) => logger.warn(message, meta),
@@ -1088,6 +1284,7 @@ export class NeuroLink {
1088
1284
  // Store config for later use and set flag for lazy initialization
1089
1285
  this.conversationMemoryConfig = config;
1090
1286
  this.conversationMemoryNeedsInit = true;
1287
+ this.validateInstanceCondensationPrompt(config.conversationMemory.memory);
1091
1288
  const memoryInitEndTime = process.hrtime.bigint();
1092
1289
  const memoryInitDurationNs = memoryInitEndTime - memoryInitStartTime;
1093
1290
  logger.debug(`[NeuroLink] ✅ LOG_POINT_C006_MEMORY_INIT_FLAG_SET_SUCCESS`, {
@@ -1122,6 +1319,59 @@ export class NeuroLink {
1122
1319
  });
1123
1320
  }
1124
1321
  }
1322
+ /**
1323
+ * Validate the condensation prompt template memory will condense with —
1324
+ * the instance `prompt`, else `HC_CONDENSATION_PROMPT` (which Hippocampus
1325
+ * reads when no config prompt is set). Hippocampus substitutes
1326
+ * placeholders with `replaceAll` and requires none, so a template missing
1327
+ * `{{NEW_CONTENT}}` yields a memory that never grows and one missing
1328
+ * `{{OLD_MEMORY}}` overwrites the summary on every turn — both silently.
1329
+ *
1330
+ * Default is disable-and-log, matching every other memory failure; the
1331
+ * log line is `error` because it is the only signal an operator gets that
1332
+ * memory is off. With `memory.strictPrompt` the constructor throws instead.
1333
+ *
1334
+ * An empty or whitespace-only template counts as unset — Hippocampus
1335
+ * resolves `config.prompt || HC_CONDENSATION_PROMPT || built-in`, so `""`
1336
+ * means "use the next one down", never "a template with no placeholders".
1337
+ */
1338
+ validateInstanceCondensationPrompt(memory) {
1339
+ if (!memory?.enabled) {
1340
+ return;
1341
+ }
1342
+ const configuredPrompt = memory.prompt?.trim() ? memory.prompt : undefined;
1343
+ const envPrompt = process.env.HC_CONDENSATION_PROMPT?.trim()
1344
+ ? process.env.HC_CONDENSATION_PROMPT
1345
+ : undefined;
1346
+ const template = configuredPrompt ?? envPrompt;
1347
+ if (template === undefined) {
1348
+ return;
1349
+ }
1350
+ const source = configuredPrompt !== undefined ? "prompt" : "env";
1351
+ const configName = source === "prompt"
1352
+ ? "conversationMemory.memory.prompt"
1353
+ : "HC_CONDENSATION_PROMPT";
1354
+ const verdict = validateCondensationPrompt(template);
1355
+ if (verdict.valid) {
1356
+ if (verdict.missing.length > 0) {
1357
+ logger.warn(`[NeuroLink] Memory condensation prompt (${configName}) has no {{MAX_WORDS}} placeholder; maxWords will not be enforced`, { source, missing: verdict.missing });
1358
+ }
1359
+ return;
1360
+ }
1361
+ const reason = `missing required placeholder(s) ${verdict.fatal
1362
+ .map((name) => `{{${name}}}`)
1363
+ .join(", ")}`;
1364
+ if (memory.strictPrompt) {
1365
+ throw ErrorFactory.invalidConfiguration(configName, reason, {
1366
+ source,
1367
+ missing: verdict.missing,
1368
+ });
1369
+ }
1370
+ this.memoryDisabledReason = `${configName}: ${reason}`;
1371
+ // `error`, not `warn`: warnings are hidden at the default log level, and
1372
+ // a host whose memory is silently off has no other way to find out.
1373
+ logger.error(`[NeuroLink] Memory disabled: condensation prompt (${configName}) is ${reason}. Without {{NEW_CONTENT}} memory never grows; without {{OLD_MEMORY}} every turn overwrites it. Fix the template or set memory.strictPrompt to fail fast.`, { source, missing: verdict.missing });
1374
+ }
1125
1375
  /**
1126
1376
  * Initialize HITL (Human-in-the-Loop) if enabled
1127
1377
  */
@@ -1727,6 +1977,7 @@ Current user's request: ${currentInput}`;
1727
1977
  */
1728
1978
  shouldReadMemory(perCallMemory, userId) {
1729
1979
  if (!this.conversationMemoryConfig?.conversationMemory?.memory?.enabled ||
1980
+ this.memoryDisabledReason ||
1730
1981
  !userId) {
1731
1982
  return false;
1732
1983
  }
@@ -1744,6 +1995,7 @@ Current user's request: ${currentInput}`;
1744
1995
  */
1745
1996
  shouldWriteMemory(perCallMemory, userId, content) {
1746
1997
  if (!this.conversationMemoryConfig?.conversationMemory?.memory?.enabled ||
1998
+ this.memoryDisabledReason ||
1747
1999
  !userId) {
1748
2000
  return false;
1749
2001
  }
@@ -1801,34 +2053,138 @@ Current user's request: ${currentInput}`;
1801
2053
  }
1802
2054
  return this.formatMultiUserMemoryContext(memories, inputText);
1803
2055
  }
2056
+ /**
2057
+ * Build the `add()` options for one memory owner. A per-owner prompt that
2058
+ * fails template validation is dropped (warned once per distinct template)
2059
+ * so that owner condenses with the instance prompt instead — Hippocampus
2060
+ * resolves `options.prompt || this.prompt`, so omitting it is the fallback.
2061
+ * An empty or whitespace-only prompt counts as unset and is omitted the
2062
+ * same way, unvalidated: `""` already falls through in Hippocampus, but
2063
+ * `" "` is truthy and would be used literally.
2064
+ */
2065
+ resolveMemoryAddOptions(owner, prompt, maxWords) {
2066
+ let effectivePrompt = prompt?.trim() ? prompt : undefined;
2067
+ if (effectivePrompt !== undefined) {
2068
+ const verdict = validateCondensationPrompt(effectivePrompt);
2069
+ if (verdict.missing.length > 0 &&
2070
+ !this.warnedCondensationPrompts.has(effectivePrompt)) {
2071
+ if (this.warnedCondensationPrompts.size >= 64) {
2072
+ this.warnedCondensationPrompts.clear();
2073
+ }
2074
+ this.warnedCondensationPrompts.add(effectivePrompt);
2075
+ if (verdict.valid) {
2076
+ logger.warn("[NeuroLink] Per-owner memory condensation prompt has no {{MAX_WORDS}} placeholder; maxWords will not be enforced for this owner", { owner, missing: verdict.missing });
2077
+ }
2078
+ else {
2079
+ logger.warn(`[NeuroLink] Per-owner memory condensation prompt is missing ${verdict.fatal
2080
+ .map((name) => `{{${name}}}`)
2081
+ .join(", ")}; using the instance prompt for this owner instead`, { owner, missing: verdict.missing });
2082
+ }
2083
+ }
2084
+ if (!verdict.valid) {
2085
+ effectivePrompt = undefined;
2086
+ }
2087
+ }
2088
+ if (effectivePrompt === undefined && maxWords === undefined) {
2089
+ return undefined;
2090
+ }
2091
+ // No `prompt` key at all when there is none to send, rather than
2092
+ // `prompt: undefined`: what reaches the client is exactly the fallback.
2093
+ return {
2094
+ ...(effectivePrompt !== undefined ? { prompt: effectivePrompt } : {}),
2095
+ ...(maxWords !== undefined ? { maxWords } : {}),
2096
+ };
2097
+ }
1804
2098
  /**
1805
2099
  * Store a conversation turn in memory (non-blocking).
1806
2100
  * Calls add(userId, content) which internally condenses old + new via LLM.
1807
2101
  * Supports additional users with per-user prompt and maxWords overrides.
1808
- */
1809
- storeMemoryInBackground(originalPrompt, responseContent, userId, additionalUsers, langfuseIdentity) {
2102
+ *
2103
+ * The write hooks (`shouldWrite`, `onBeforeStore`; per-call wins over
2104
+ * instance) run inside the deferred closure so an async hook never sits on
2105
+ * the response path. A `false` / `null` verdict, a non-string transform or
2106
+ * a thrown hook skips every `add()` for the turn — a missed memory is
2107
+ * recoverable, a polluted condensed summary is not.
2108
+ */
2109
+ storeMemoryInBackground(turn, perCallMemory, langfuseIdentity) {
2110
+ const { userId } = turn;
2111
+ const instanceHooks = this.conversationMemoryConfig?.conversationMemory?.memory;
2112
+ const shouldWrite = perCallMemory?.shouldWrite ?? instanceHooks?.shouldWrite;
2113
+ const onBeforeStore = perCallMemory?.onBeforeStore ?? instanceHooks?.onBeforeStore;
1810
2114
  const memoryWrite = async () => {
1811
2115
  try {
2116
+ // A write scheduled after shutdown()/dispose() finished draining has
2117
+ // no host left to condense on; landing it would rebuild the released
2118
+ // condenser. (A write scheduled before is what the drain waits for.)
2119
+ if (this.memoryCondenserReleased) {
2120
+ logger.debug("[NeuroLink] Memory write skipped: the instance has been shut down", { userId });
2121
+ return;
2122
+ }
1812
2123
  const client = this.ensureMemoryReady();
1813
2124
  if (!client) {
1814
2125
  return;
1815
2126
  }
1816
- const content = `User: ${originalPrompt}\nAssistant: ${responseContent}`;
2127
+ if (shouldWrite) {
2128
+ let allowed;
2129
+ try {
2130
+ allowed = await withTimeout(Promise.resolve(shouldWrite(turn)), MEMORY_WRITE_TIMEOUT_MS, new Error(`memory.shouldWrite exceeded ${MEMORY_WRITE_TIMEOUT_MS}ms`));
2131
+ }
2132
+ catch (error) {
2133
+ logger.warn("[NeuroLink] memory.shouldWrite hook threw; skipping memory write for this turn", {
2134
+ userId,
2135
+ error: error instanceof Error ? error.message : String(error),
2136
+ });
2137
+ return;
2138
+ }
2139
+ if (!allowed) {
2140
+ logger.debug("[NeuroLink] memory.shouldWrite declined the turn", {
2141
+ userId,
2142
+ });
2143
+ return;
2144
+ }
2145
+ }
2146
+ let content = `User: ${turn.prompt}\nAssistant: ${turn.response}`;
2147
+ if (onBeforeStore) {
2148
+ let transformed;
2149
+ try {
2150
+ transformed = await withTimeout(Promise.resolve(onBeforeStore(content, turn)), MEMORY_WRITE_TIMEOUT_MS, new Error(`memory.onBeforeStore exceeded ${MEMORY_WRITE_TIMEOUT_MS}ms`));
2151
+ }
2152
+ catch (error) {
2153
+ logger.warn("[NeuroLink] memory.onBeforeStore hook threw; skipping memory write for this turn", {
2154
+ userId,
2155
+ error: error instanceof Error ? error.message : String(error),
2156
+ });
2157
+ return;
2158
+ }
2159
+ if (transformed === null) {
2160
+ logger.debug("[NeuroLink] memory.onBeforeStore vetoed the turn", {
2161
+ userId,
2162
+ });
2163
+ return;
2164
+ }
2165
+ if (typeof transformed !== "string") {
2166
+ logger.warn("[NeuroLink] memory.onBeforeStore returned a non-string; skipping memory write for this turn", { userId, returnedType: typeof transformed });
2167
+ return;
2168
+ }
2169
+ content = transformed;
2170
+ }
2171
+ if (!content.trim()) {
2172
+ return;
2173
+ }
1817
2174
  // Collect all users to write: primary + additional users with write !== false
1818
- const writeOps = [client.add(userId, content)];
1819
- const writableAdditional = (additionalUsers || []).filter((u) => u.write !== false);
2175
+ const writeOps = [
2176
+ client.add(userId, content, this.resolveMemoryAddOptions(userId, perCallMemory?.prompt, perCallMemory?.maxWords)),
2177
+ ];
2178
+ const writableAdditional = (perCallMemory?.additionalUsers || []).filter((u) => u.write !== false);
1820
2179
  for (const user of writableAdditional) {
1821
- const addOptions = user.prompt || user.maxWords
1822
- ? { prompt: user.prompt, maxWords: user.maxWords }
1823
- : undefined;
1824
- writeOps.push(client.add(user.userId, content, addOptions));
2180
+ writeOps.push(client.add(user.userId, content, this.resolveMemoryAddOptions(user.userId, user.prompt, user.maxWords)));
1825
2181
  }
1826
2182
  // withTimeout races against Promise.all — if the timeout fires, the
1827
2183
  // await resolves with an error but the underlying client.add() calls
1828
2184
  // may still complete in the background. This is acceptable: the memory
1829
2185
  // client API (Mem0) doesn't support AbortSignal, and these are
1830
2186
  // fire-and-forget background writes where a stale completion is harmless.
1831
- await withTimeout(Promise.all(writeOps), 30_000, new Error("Background memory write timed out after 30s"));
2187
+ await withTimeout(Promise.all(writeOps), MEMORY_WRITE_TIMEOUT_MS, new Error(`Background memory write timed out after ${MEMORY_WRITE_TIMEOUT_MS}ms`));
1832
2188
  }
1833
2189
  catch (error) {
1834
2190
  logger.warn("Memory storage failed:", error);
@@ -1848,7 +2204,19 @@ Current user's request: ${currentInput}`;
1848
2204
  traceName: langfuseIdentity.traceName ?? null,
1849
2205
  }, memoryWrite)
1850
2206
  : runWithCurrentLangfuseContext(memoryWrite);
1851
- setImmediate(wrappedMemoryWrite);
2207
+ // Tracked from the moment it is scheduled, not from when setImmediate
2208
+ // fires: shutdown() typically runs in that gap, and a write it cannot
2209
+ // see is a write it cannot wait for. `memoryWrite` never rejects (it
2210
+ // catches internally), so the settle only ever resolves.
2211
+ const scheduled = new Promise((resolve) => {
2212
+ setImmediate(() => {
2213
+ Promise.resolve()
2214
+ .then(wrappedMemoryWrite)
2215
+ .then(() => resolve(), () => resolve());
2216
+ });
2217
+ });
2218
+ this.pendingMemoryWrites.add(scheduled);
2219
+ void scheduled.then(() => this.pendingMemoryWrites.delete(scheduled));
1852
2220
  }
1853
2221
  /**
1854
2222
  * Set up HITL event forwarding to main emitter
@@ -2844,6 +3212,12 @@ Current user's request: ${currentInput}`;
2844
3212
  catch (error) {
2845
3213
  logger.warn("[NeuroLink] Background work cleanup failed:", error);
2846
3214
  }
3215
+ try {
3216
+ await this.releaseMemoryCondenser();
3217
+ }
3218
+ catch (error) {
3219
+ logger.warn("[NeuroLink] Memory condenser shutdown failed:", error);
3220
+ }
2847
3221
  try {
2848
3222
  await flushOpenTelemetry();
2849
3223
  await shutdownOpenTelemetry();
@@ -4042,16 +4416,40 @@ Current user's request: ${currentInput}`;
4042
4416
  /**
4043
4417
  * Dependencies handed to every `ContextCompactor`.
4044
4418
  *
4045
- * `tryDecide` returns null whenever no decision provider is configured or
4419
+ * `siteDecide` returns null whenever no decision provider is configured or
4046
4420
  * the call fails, so passing this unconditionally is safe: the compactor
4047
4421
  * runs its previous pipeline unchanged and pays nothing for the option.
4422
+ * `outer` is the request the compaction runs for — its credentials, abort
4423
+ * signal and ids — so both decision stages go to the caller's account.
4048
4424
  */
4049
- contextCompactorDeps() {
4425
+ contextCompactorDeps(outer) {
4050
4426
  return {
4051
- decide: (decisionOptions) => this.tryDecide(decisionOptions),
4427
+ decide: (decisionOptions) => this.siteDecide(decisionOptions, outer),
4052
4428
  relevance: this.contextRelevanceOptions,
4053
4429
  };
4054
4430
  }
4431
+ /**
4432
+ * What a decide site inherits from the request it runs inside. Read off the
4433
+ * request options so every site — routing, tool routing, both compaction
4434
+ * stages — forwards the same fields the same way. The request id is the
4435
+ * documented top-level `requestId` first, then `context.requestId`.
4436
+ */
4437
+ decisionSiteContext(options, sessionId) {
4438
+ const requestContext = options.context;
4439
+ const requestId = options.requestId ??
4440
+ (typeof requestContext?.requestId === "string"
4441
+ ? requestContext.requestId
4442
+ : undefined);
4443
+ const contextSessionId = typeof requestContext?.sessionId === "string"
4444
+ ? requestContext.sessionId
4445
+ : undefined;
4446
+ return {
4447
+ credentials: options.credentials,
4448
+ signal: options.abortSignal,
4449
+ sessionId: sessionId ?? contextSessionId,
4450
+ requestId,
4451
+ };
4452
+ }
4055
4453
  /**
4056
4454
  * Applies the host-configured `classifierRouter` to `options` in place.
4057
4455
  *
@@ -4093,6 +4491,10 @@ Current user's request: ${currentInput}`;
4093
4491
  const priorMessageCount = Array.isArray(opt.conversationMessages)
4094
4492
  ? opt.conversationMessages.length
4095
4493
  : undefined;
4494
+ // The decision call inherits this request's credentials, abort signal
4495
+ // and ids exactly as the other sites do, so it reaches the caller's
4496
+ // account, dies with the turn, and correlates on the same requestId.
4497
+ const siteContext = this.decisionSiteContext(opt, sessionId);
4096
4498
  try {
4097
4499
  const decision = await this.classifierRouter.route({
4098
4500
  prompt: promptText,
@@ -4103,6 +4505,9 @@ Current user's request: ${currentInput}`;
4103
4505
  sessionId,
4104
4506
  sessionBound,
4105
4507
  priorMessageCount,
4508
+ credentials: siteContext.credentials,
4509
+ signal: siteContext.signal,
4510
+ requestId: siteContext.requestId,
4106
4511
  });
4107
4512
  if (!decision) {
4108
4513
  return;
@@ -4276,7 +4681,9 @@ Current user's request: ${currentInput}`;
4276
4681
  maxSteps: options.maxSteps,
4277
4682
  toolRoots: options.toolRoots,
4278
4683
  toolChoice: options.toolChoice,
4684
+ toolChoiceSteps: options.toolChoiceSteps,
4279
4685
  prepareStep: options.prepareStep,
4686
+ replayToolSteps: options.replayToolSteps,
4280
4687
  enabledToolNames: options.enabledToolNames,
4281
4688
  enableAnalytics: options.enableAnalytics,
4282
4689
  enableEvaluation: options.enableEvaluation,
@@ -4656,7 +5063,19 @@ Current user's request: ${currentInput}`;
4656
5063
  // Memory storage
4657
5064
  if (this.shouldWriteMemory(options.memory, options.context?.userId, generateResult.content) &&
4658
5065
  options.context?.userId) {
4659
- this.storeMemoryInBackground(originalPrompt ?? "", generateResult.content.trim(), options.context.userId, options.memory?.additionalUsers, options.context);
5066
+ const sessionId = options.context.sessionId;
5067
+ this.storeMemoryInBackground({
5068
+ prompt: originalPrompt ?? "",
5069
+ response: generateResult.content.trim(),
5070
+ userId: options.context.userId,
5071
+ ...(typeof sessionId === "string" ? { sessionId } : {}),
5072
+ provider: generateResult.provider,
5073
+ model: generateResult.model,
5074
+ toolsUsed: generateResult.toolsUsed ??
5075
+ generateResult.toolExecutions?.map((t) => t.toolName),
5076
+ usage: generateResult.usage,
5077
+ finishReason: generateResult.finishReason,
5078
+ }, options.memory, options.context);
4660
5079
  }
4661
5080
  }
4662
5081
  /**
@@ -5310,7 +5729,7 @@ Current user's request: ${currentInput}`;
5310
5729
  enableDeduplicate: true,
5311
5730
  enableTruncate: true,
5312
5731
  truncationFraction: fraction,
5313
- }, this.contextCompactorDeps());
5732
+ }, this.contextCompactorDeps(this.decisionSiteContext(options)));
5314
5733
  const compactionResult = await compactor.compact(originalMessages, compactionTarget, undefined, options.context?.requestId);
5315
5734
  if (!compactionResult.compacted) {
5316
5735
  continue;
@@ -5323,6 +5742,7 @@ Current user's request: ${currentInput}`;
5323
5742
  maxTokens: options.maxTokens,
5324
5743
  systemPrompt: options.systemPrompt,
5325
5744
  currentPrompt: options.prompt,
5745
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
5326
5746
  conversationMessages: repairedResult.messages,
5327
5747
  });
5328
5748
  if (verifyBudget.withinBudget) {
@@ -5509,8 +5929,7 @@ Current user's request: ${currentInput}`;
5509
5929
  * Attempt MCP generation with retry logic
5510
5930
  */
5511
5931
  async attemptMCPGeneration(options, generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, functionTag) {
5512
- if (!options.disableTools &&
5513
- !(options.tts?.enabled && !options.tts?.useAiResponse)) {
5932
+ if (!options.disableTools && !isDirectTTSRequest(options.tts)) {
5514
5933
  return await this.performMCPGenerationRetries(options, generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, functionTag);
5515
5934
  }
5516
5935
  return null;
@@ -5874,6 +6293,26 @@ Current user's request: ${currentInput}`;
5874
6293
  // Ignore serialization errors in debug logging
5875
6294
  }
5876
6295
  }
6296
+ /**
6297
+ * The replay mode the budget check should size stored tool rows by: the
6298
+ * request's `replayToolSteps`, else the instance's, else the builder's
6299
+ * `"marker"` default — the same resolution BaseProvider applies before
6300
+ * building messages, so the estimate matches the prompt.
6301
+ *
6302
+ * Reach: every `checkContextBudget` call on the generate and stream paths,
6303
+ * including the post-compaction and post-truncation re-checks. It does NOT
6304
+ * reach the compactor's own stage gates or the summarizer's sizing
6305
+ * (`ContextCompactor`, `SummarizationEngine`): those still count tool rows
6306
+ * at stored size, so under `"marker"` / `"off"` a stage may prune or
6307
+ * summarize more history than the prompt would have carried. Threading the
6308
+ * mode into the compactor is a follow-up, tracked in
6309
+ * docs/features/context-compaction.md ("Context budget").
6310
+ */
6311
+ resolveToolReplayModeForBudget(options) {
6312
+ return (options.replayToolSteps ??
6313
+ this.conversationMemory?.config?.replayToolSteps ??
6314
+ "marker");
6315
+ }
5877
6316
  async ensureMCPGenerationBudget(options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages) {
5878
6317
  const budgetResult = checkContextBudget({
5879
6318
  provider: providerName,
@@ -5884,6 +6323,7 @@ Current user's request: ${currentInput}`;
5884
6323
  currentPrompt: options.prompt,
5885
6324
  toolDefinitions: availableTools,
5886
6325
  compactionThreshold: options.compactionThreshold,
6326
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
5887
6327
  });
5888
6328
  logger.info("[TokenBudget] Token breakdown", {
5889
6329
  requestId,
@@ -5946,7 +6386,7 @@ Current user's request: ${currentInput}`;
5946
6386
  summarizationProvider: this.conversationMemoryConfig?.conversationMemory
5947
6387
  ?.summarizationProvider,
5948
6388
  summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
5949
- }, this.contextCompactorDeps());
6389
+ }, this.contextCompactorDeps(this.decisionSiteContext(options, compactionSessionId)));
5950
6390
  // Fixed overhead (system + prompt + tools + files) already exceeds the
5951
6391
  // window — no amount of history compaction can fit this request, and
5952
6392
  // compacting to an empty history would only hide the real cause.
@@ -5979,6 +6419,7 @@ Current user's request: ${currentInput}`;
5979
6419
  systemPrompt: enhancedSystemPrompt,
5980
6420
  conversationMessages: compactedMessages,
5981
6421
  currentPrompt: options.prompt,
6422
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
5982
6423
  toolDefinitions: availableTools,
5983
6424
  });
5984
6425
  if (postCompactBudget.withinBudget) {
@@ -6000,6 +6441,7 @@ Current user's request: ${currentInput}`;
6000
6441
  systemPrompt: enhancedSystemPrompt,
6001
6442
  conversationMessages: compactedMessages,
6002
6443
  currentPrompt: options.prompt,
6444
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
6003
6445
  toolDefinitions: availableTools,
6004
6446
  });
6005
6447
  if (!finalBudget.withinBudget) {
@@ -6337,6 +6779,7 @@ Current user's request: ${currentInput}`;
6337
6779
  systemPrompt: options.systemPrompt,
6338
6780
  conversationMessages: conversationMessages,
6339
6781
  currentPrompt: options.prompt,
6782
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
6340
6783
  toolDefinitions: options.tools
6341
6784
  ? Object.values(options.tools)
6342
6785
  : undefined,
@@ -6401,6 +6844,7 @@ Current user's request: ${currentInput}`;
6401
6844
  systemPrompt: options.systemPrompt,
6402
6845
  conversationMessages: [],
6403
6846
  currentPrompt: windowed,
6847
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
6404
6848
  toolDefinitions: options.tools
6405
6849
  ? Object.values(options.tools)
6406
6850
  : undefined,
@@ -6483,7 +6927,7 @@ Current user's request: ${currentInput}`;
6483
6927
  ?.summarizationProvider,
6484
6928
  summarizationModel: this.conversationMemoryConfig?.conversationMemory
6485
6929
  ?.summarizationModel,
6486
- }, this.contextCompactorDeps());
6930
+ }, this.contextCompactorDeps(this.decisionSiteContext(options, dpgCompactionSessionId)));
6487
6931
  const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(budgetCheck, options.compactionThreshold), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId, { currentRequest: options.prompt });
6488
6932
  if (compactionResult.compacted) {
6489
6933
  const repairedResult = repairToolPairs(compactionResult.messages);
@@ -6498,6 +6942,7 @@ Current user's request: ${currentInput}`;
6498
6942
  systemPrompt: options.systemPrompt,
6499
6943
  conversationMessages: conversationMessages,
6500
6944
  currentPrompt: options.prompt,
6945
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
6501
6946
  toolDefinitions: options.tools
6502
6947
  ? Object.values(options.tools)
6503
6948
  : undefined,
@@ -6536,6 +6981,7 @@ Current user's request: ${currentInput}`;
6536
6981
  systemPrompt: options.systemPrompt,
6537
6982
  conversationMessages: conversationMessages,
6538
6983
  currentPrompt: options.prompt,
6984
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
6539
6985
  toolDefinitions: options.tools
6540
6986
  ? Object.values(options.tools)
6541
6987
  : undefined,
@@ -7512,12 +7958,10 @@ Current user's request: ${currentInput}`;
7512
7958
  abortSignal: options.abortSignal,
7513
7959
  })),
7514
7960
  // Calibrated per-server routing when a decision provider is
7515
- // configured. tryDecide returns null without one, so the resolver
7516
- // falls straight through to the generative router as before.
7517
- decideFn: (decisionOptions) => this.tryDecide({
7518
- ...decisionOptions,
7519
- signal: options.abortSignal,
7520
- }),
7961
+ // configured. siteDecide returns null without one, so the resolver
7962
+ // falls straight through to the generative router as before. The
7963
+ // outer request's credentials, abort signal and ids ride along.
7964
+ decideFn: (decisionOptions) => this.siteDecide(decisionOptions, this.decisionSiteContext(options, sessionId || undefined)),
7521
7965
  decisionMinDropConfidence: routingConfig.minDropConfidence,
7522
7966
  emitDecision: captureDecision,
7523
7967
  // L2 / ITEM D — only populated when embedding is configured.
@@ -8198,6 +8642,16 @@ Current user's request: ${currentInput}`;
8198
8642
  accumulatedContent,
8199
8643
  startTime,
8200
8644
  eventSequence,
8645
+ // Read only now, after the stream drained: the provider's
8646
+ // toolsUsed is a live getter that resolves as the loop runs.
8647
+ resultToolsUsed: (() => {
8648
+ const fromResult = mcpStreamOutcome.toolsUsed;
8649
+ if (Array.isArray(fromResult) && fromResult.length > 0) {
8650
+ return fromResult;
8651
+ }
8652
+ const fromCalls = currentToolCalls().map((t) => t.toolName);
8653
+ return fromCalls.length > 0 ? fromCalls : undefined;
8654
+ })(),
8201
8655
  });
8202
8656
  }
8203
8657
  })();
@@ -8715,7 +9169,7 @@ Current user's request: ${currentInput}`;
8715
9169
  * Handles conversation memory storage in the background.
8716
9170
  */
8717
9171
  async storeStreamConversationMemory(params) {
8718
- const { enhancedOptions, providerName, originalPrompt, accumulatedContent, startTime, eventSequence, } = params;
9172
+ const { enhancedOptions, providerName, originalPrompt, accumulatedContent, startTime, eventSequence, resultToolsUsed, } = params;
8719
9173
  // Logger Guard: the full options object (history + tool outputs) can be
8720
9174
  // enormous — an eager JSON.stringify here threw RangeError: Invalid
8721
9175
  // string length in production and killed the turn. Serialize lazily,
@@ -8793,7 +9247,26 @@ Current user's request: ${currentInput}`;
8793
9247
  }
8794
9248
  }
8795
9249
  if (this.shouldWriteMemory(enhancedOptions.memory, enhancedOptions.context?.userId, accumulatedContent)) {
8796
- this.storeMemoryInBackground(originalPrompt ?? "", accumulatedContent.trim(), enhancedOptions.context?.userId, enhancedOptions.memory?.additionalUsers, enhancedOptions.context);
9250
+ const streamSessionId = enhancedOptions.context?.sessionId;
9251
+ // Tool names come from THIS request's stream result ONLY, as generate()
9252
+ // reads them off its own result — an empty list is authoritative. The
9253
+ // captured tool:start events are never consulted: they are collected
9254
+ // by instance-wide listeners, so a stream that used no tools, running
9255
+ // beside any other tool call on the same instance, would otherwise
9256
+ // hand that tool's name to its shouldWrite / onBeforeStore.
9257
+ // usage/finishReason stay unavailable.
9258
+ const toolsUsed = [...new Set(resultToolsUsed ?? [])];
9259
+ this.storeMemoryInBackground({
9260
+ prompt: originalPrompt ?? "",
9261
+ response: accumulatedContent.trim(),
9262
+ userId: enhancedOptions.context?.userId,
9263
+ ...(typeof streamSessionId === "string"
9264
+ ? { sessionId: streamSessionId }
9265
+ : {}),
9266
+ provider: providerName,
9267
+ model: enhancedOptions.model,
9268
+ ...(toolsUsed.length > 0 ? { toolsUsed } : {}),
9269
+ }, enhancedOptions.memory, enhancedOptions.context);
8797
9270
  }
8798
9271
  }
8799
9272
  /**
@@ -8903,6 +9376,7 @@ Current user's request: ${currentInput}`;
8903
9376
  model: options.model,
8904
9377
  maxTokens: options.maxTokens,
8905
9378
  systemPrompt: enhancedSystemPrompt,
9379
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
8906
9380
  conversationMessages: conversationMessages,
8907
9381
  currentPrompt: options.input.text,
8908
9382
  toolDefinitions: availableTools,
@@ -8956,7 +9430,7 @@ Current user's request: ${currentInput}`;
8956
9430
  ?.summarizationProvider,
8957
9431
  summarizationModel: this.conversationMemoryConfig?.conversationMemory
8958
9432
  ?.summarizationModel,
8959
- }, this.contextCompactorDeps());
9433
+ }, this.contextCompactorDeps(this.decisionSiteContext(options, streamCompactionSessionId)));
8960
9434
  const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(streamBudget, options.compactionThreshold), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId, { currentRequest: options.input.text });
8961
9435
  if (compactionResult.compacted) {
8962
9436
  const repairedResult = repairToolPairs(compactionResult.messages);
@@ -8973,6 +9447,7 @@ Current user's request: ${currentInput}`;
8973
9447
  model: options.model,
8974
9448
  maxTokens: options.maxTokens,
8975
9449
  systemPrompt: enhancedSystemPrompt,
9450
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
8976
9451
  conversationMessages: conversationMessages,
8977
9452
  currentPrompt: options.input.text,
8978
9453
  toolDefinitions: availableTools,
@@ -9012,6 +9487,7 @@ Current user's request: ${currentInput}`;
9012
9487
  model: options.model,
9013
9488
  maxTokens: options.maxTokens,
9014
9489
  systemPrompt: enhancedSystemPrompt,
9490
+ toolReplayMode: this.resolveToolReplayModeForBudget(options),
9015
9491
  conversationMessages: conversationMessages,
9016
9492
  currentPrompt: options.input.text,
9017
9493
  toolDefinitions: availableTools,
@@ -13022,14 +13498,366 @@ Current user's request: ${currentInput}`;
13022
13498
  * @throws when no decision provider is configured, or the call fails
13023
13499
  */
13024
13500
  async decide(options) {
13501
+ return this.runDecide(options);
13502
+ }
13503
+ /**
13504
+ * The provider a decision call will go to, resolved exactly as `decide()`
13505
+ * resolves it: an explicit name (or alias) wins, else the first configured
13506
+ * decision provider counting SDK credentials as well as the environment.
13507
+ */
13508
+ resolveDecisionProviderName(provider, credentials) {
13509
+ return (provider ??
13510
+ resolveDefaultDecisionProvider(this.resolveCredentials(credentials)));
13511
+ }
13512
+ /**
13513
+ * The model `decide()` sends when the caller names none: the one the
13514
+ * provider factory registered — `<PROVIDER>_MODEL` as it read at
13515
+ * registration, else the descriptor default. `decisionLimits()` must
13516
+ * report this same value rather than re-read the environment, or a
13517
+ * `LAYA_MODEL` changed after registration would size a state against one
13518
+ * checkpoint's window while the pre-flight check applies another's.
13519
+ * Before registration has run the registry is empty, and the factory will
13520
+ * read the environment when it does — so that read is returned here.
13521
+ */
13522
+ registeredDecisionModel(descriptor) {
13523
+ const registered = ProviderFactory.getProviderInfo(descriptor.name)?.defaultModel;
13524
+ if (registered) {
13525
+ return registered;
13526
+ }
13527
+ const envModel = descriptor.envVars.model
13528
+ ? process.env[descriptor.envVars.model]
13529
+ : undefined;
13530
+ return envModel || descriptor.defaultModel;
13531
+ }
13532
+ /**
13533
+ * What the decision provider can read, resolved for one model — the same
13534
+ * figures the pre-flight refusal compares against, so a host can size the
13535
+ * questions it adds through `decisionHooks` (or a state it is about to
13536
+ * send) before the call instead of after the refusal.
13537
+ *
13538
+ * Provider resolution is `decide()`'s; the model is `query.model`, else
13539
+ * the model `decide()` would send (see `registeredDecisionModel`); the
13540
+ * per-model entry is flattened over the base. `enforcedLocally` says
13541
+ * whether NeuroLink refuses an over-limit request itself (Laya) or only
13542
+ * reports the server's ceiling (TypeSafe, whose limits are advisory).
13543
+ *
13544
+ * Returns null when no decision provider is configured and none is named —
13545
+ * parity with `tryDecide` — and for a provider that declares no limits.
13546
+ * A named provider is read whether or not it is configured: its limits are
13547
+ * facts about the model, not about this host's credentials.
13548
+ */
13549
+ decisionLimits(query) {
13550
+ const requested = this.resolveDecisionProviderName(query?.provider, query?.credentials);
13551
+ if (!requested) {
13552
+ return null;
13553
+ }
13554
+ const canonical = PROVIDER_ALIAS_INDEX.get(requested.toLowerCase()) ?? requested;
13555
+ const descriptor = PROVIDER_DESCRIPTORS_BY_NAME.get(canonical);
13556
+ if (!descriptor) {
13557
+ return null;
13558
+ }
13559
+ const model = query?.model ?? this.registeredDecisionModel(descriptor);
13560
+ return resolveDecisionLimitsReading(descriptor, model);
13561
+ }
13562
+ /**
13563
+ * `decisionHooks.hookTimeoutMs`, validated once per instance. A timer
13564
+ * honours delays from 1 to 2^31 − 1 ms and fires at once for anything
13565
+ * else — so `Infinity`, meant to lift the bound, would instead time every
13566
+ * hook out immediately, as would 0, NaN or a negative. Such a value falls
13567
+ * back to the default with one warning.
13568
+ */
13569
+ decisionHookTimeout() {
13570
+ if (this.decisionHookTimeoutMs !== undefined) {
13571
+ return this.decisionHookTimeoutMs;
13572
+ }
13573
+ const configured = this.decisionHooks?.hookTimeoutMs;
13574
+ let resolved = DEFAULT_DECISION_HOOK_TIMEOUT_MS;
13575
+ if (configured !== undefined) {
13576
+ if (typeof configured === "number" &&
13577
+ Number.isFinite(configured) &&
13578
+ configured > 0 &&
13579
+ configured <= MAX_DECISION_HOOK_TIMEOUT_MS) {
13580
+ resolved = configured;
13581
+ }
13582
+ else {
13583
+ logger.warn(`[Decision] decisionHooks.hookTimeoutMs must be a finite number from 1 to ${MAX_DECISION_HOOK_TIMEOUT_MS} ms; using the default ${DEFAULT_DECISION_HOOK_TIMEOUT_MS} ms`, { configured: String(configured) });
13584
+ }
13585
+ }
13586
+ this.decisionHookTimeoutMs = resolved;
13587
+ return resolved;
13588
+ }
13589
+ /**
13590
+ * `EventEmitter.emit` runs listeners synchronously and rethrows whatever
13591
+ * they throw. A decision event is observe-only, so a throwing listener
13592
+ * must not be able to change the turn's routing or compaction outcome.
13593
+ */
13594
+ emitDecisionEvent(name, payload) {
13595
+ try {
13596
+ this.emitter.emit(name, payload);
13597
+ }
13598
+ catch (error) {
13599
+ logger.warn(`[Decision] a ${name} listener threw`, {
13600
+ error: error instanceof Error ? error.message : String(error),
13601
+ });
13602
+ }
13603
+ }
13604
+ /**
13605
+ * The one path every fail-open decision takes — a host's own `tryDecide`
13606
+ * and all five built-in sites (routing, tool routing, both compaction
13607
+ * stages, RAG planning) end up here.
13608
+ *
13609
+ * For a request stamped with a {@link DecisionSite} this is also where the
13610
+ * host's `decisionHooks` ride along:
13611
+ *
13612
+ * 1. `extendQuestions` may add questions; a throw is logged and ignored.
13613
+ * 2. Each host question is validated (invalid ones dropped, with a warning)
13614
+ * and namespaced `host__N` on the wire, so it can never collide with
13615
+ * NeuroLink's own ids (`difficulty`, `server__N`, `msg__N`, …).
13616
+ * 3. Additions are capped at the provider's `maxQuestions` minus
13617
+ * NeuroLink's own count. Without the cap a host adding one question too
13618
+ * many would have the WHOLE request refused, and NeuroLink's own routing
13619
+ * would silently degrade to the heuristic — the host's problem would
13620
+ * become NeuroLink's. A provider that declares no `maxQuestions`
13621
+ * (TypeSafe, XOR) takes every well-formed addition.
13622
+ * 4. Answers are split: the consumer sees only its own ids, exactly as it
13623
+ * would without hooks; `onAnswers` gets the host's answers under the
13624
+ * host's original ids plus the full result.
13625
+ *
13626
+ * The request's `images` and `video` ride along untouched — `merged` and
13627
+ * the wire call spread the request, and only `state`, the questions and
13628
+ * the result are cloned — so an XOR request keeps its Buffers and its
13629
+ * result keeps `mediaBytes`.
13630
+ *
13631
+ * `decision:before` / `decision:after` fire once per site call on the
13632
+ * instance emitter, observe-only, whether or not the call produced a
13633
+ * result. Without hooks and listeners, the decision payload — state and
13634
+ * questions — is exactly what the consumer built; the outer request's
13635
+ * per-call `credentials` are still forwarded, so the account and base URL
13636
+ * on the wire can differ from the instance's.
13637
+ *
13638
+ * Fail-open by construction, in three layers. Without a decision provider
13639
+ * nothing runs at all — no hook, no event — and the consumer gets the null
13640
+ * it always got. Everything a host can reach (its hook's return value, the
13641
+ * event payloads, the answer split, `onAnswers`) runs inside one `try`;
13642
+ * whatever a host throws, the consumer gets what `tryRunDecide` alone
13643
+ * would have given it. And every host-facing value is a `structuredClone`,
13644
+ * so a hook or listener that edits what it is handed edits neither the
13645
+ * wire request nor the consumer's own question objects.
13646
+ *
13647
+ * `outer` is what the surrounding `generate()` / `stream()` supplies —
13648
+ * per-call credentials, abort signal, ids — and wins over the same fields
13649
+ * on the request.
13650
+ */
13651
+ async siteDecide(request, outer) {
13652
+ const merged = {
13653
+ ...request,
13654
+ credentials: outer?.credentials ?? request.credentials,
13655
+ // Either cancellation must win: the outer call's abort and the site's
13656
+ // own deadline are independent limits.
13657
+ signal: outer?.signal && request.signal
13658
+ ? AbortSignal.any([outer.signal, request.signal])
13659
+ : (outer?.signal ?? request.signal),
13660
+ sessionId: outer?.sessionId ?? request.sessionId,
13661
+ requestId: outer?.requestId ?? request.requestId,
13662
+ };
13663
+ const site = merged.site;
13664
+ if (!site) {
13665
+ return this.tryRunDecide(merged);
13666
+ }
13667
+ // Inert without a decision provider: a host's hook must not sit on the
13668
+ // request path of a tenant that has no decision model to reach, and an
13669
+ // event for a call that could never be made would only mislead.
13670
+ if (!this.resolveDecisionProviderName(merged.provider, merged.credentials)) {
13671
+ return null;
13672
+ }
13673
+ // Wire id → the host's own id, so answers can be handed back under the
13674
+ // ids the host used, and NeuroLink's consumers never see a host id. A Map,
13675
+ // so a host id such as `__proto__` is an ordinary key.
13676
+ const hostIdByWireId = new Map();
13677
+ // Set once the wire call has returned, so the catch below knows whether
13678
+ // to fall back to a fresh call or to hand over what was already obtained.
13679
+ let obtained;
13680
+ try {
13681
+ const ownQuestions = merged.questions;
13682
+ const ownCount = Object.keys(ownQuestions).length;
13683
+ const hooks = this.decisionHooks;
13684
+ const hookTimeoutMs = this.decisionHookTimeout();
13685
+ // Host-facing copies of the consumer's state and questions, made once
13686
+ // and only when something will receive them. The consumer's question
13687
+ // objects are often module-level rubric constants; a listener that
13688
+ // edited one in place would otherwise rewrite every later call.
13689
+ let hostView;
13690
+ const hostFacing = () => {
13691
+ hostView ??= {
13692
+ state: structuredClone(merged.state),
13693
+ questions: structuredClone(ownQuestions),
13694
+ };
13695
+ return hostView;
13696
+ };
13697
+ const hookContext = () => ({
13698
+ site,
13699
+ ...hostFacing(),
13700
+ sessionId: merged.sessionId,
13701
+ requestId: merged.requestId,
13702
+ });
13703
+ let questions = ownQuestions;
13704
+ if (hooks?.extendQuestions) {
13705
+ let proposed;
13706
+ try {
13707
+ proposed =
13708
+ (await withTimeout(Promise.resolve(hooks.extendQuestions(hookContext())), hookTimeoutMs, new Error(`extendQuestions exceeded ${hookTimeoutMs}ms`))) ?? undefined;
13709
+ }
13710
+ catch (error) {
13711
+ logger.warn(`[Decision] extendQuestions hook failed for site "${site}" — proceeding with NeuroLink's own questions`, { error: error instanceof Error ? error.message : String(error) });
13712
+ }
13713
+ const proposedEntries = proposed ? Object.entries(proposed) : [];
13714
+ if (proposedEntries.length > 0) {
13715
+ const limits = this.decisionLimits({
13716
+ provider: merged.provider,
13717
+ model: merged.model,
13718
+ credentials: merged.credentials,
13719
+ });
13720
+ // A provider without a question-count cap (TypeSafe, XOR) takes
13721
+ // every well-formed addition; its token/byte limits still apply
13722
+ // and are what `decisionLimits()` reports for sizing.
13723
+ const room = limits?.maxQuestions !== undefined
13724
+ ? Math.max(0, limits.maxQuestions - ownCount)
13725
+ : Number.POSITIVE_INFINITY;
13726
+ const combined = {
13727
+ ...ownQuestions,
13728
+ };
13729
+ let invalidCount = 0;
13730
+ let overflowCount = 0;
13731
+ for (const [hostId, proposal] of proposedEntries) {
13732
+ // The wire carries a snapshot of the host's question, never the
13733
+ // host's object: validated as a copy, sent as that copy.
13734
+ const question = snapshotDecisionQuestion(proposal);
13735
+ if (question === undefined) {
13736
+ invalidCount += 1;
13737
+ continue;
13738
+ }
13739
+ if (hostIdByWireId.size >= room) {
13740
+ overflowCount += 1;
13741
+ continue;
13742
+ }
13743
+ const wireId = decisionKey(HOST_DECISION_NAMESPACE, hostIdByWireId.size);
13744
+ combined[wireId] = question;
13745
+ hostIdByWireId.set(wireId, hostId);
13746
+ }
13747
+ if (invalidCount > 0) {
13748
+ logger.warn(`[Decision] dropped ${invalidCount} malformed host question(s) for site "${site}"`);
13749
+ }
13750
+ if (overflowCount > 0 && limits) {
13751
+ logger.warn(`[Decision] dropped ${overflowCount} host question(s) for site "${site}": ${limits.provider} accepts ${limits.maxQuestions} per request and NeuroLink asks ${ownCount}`);
13752
+ }
13753
+ questions = combined;
13754
+ }
13755
+ }
13756
+ const hostQuestionCount = hostIdByWireId.size;
13757
+ if (this.emitter.listenerCount("decision:before") > 0) {
13758
+ const beforeEvent = {
13759
+ site,
13760
+ state: hostFacing().state,
13761
+ questions: hostQuestionCount === 0
13762
+ ? hostFacing().questions
13763
+ : structuredClone(questions),
13764
+ hostQuestionCount,
13765
+ sessionId: merged.sessionId,
13766
+ requestId: merged.requestId,
13767
+ };
13768
+ this.emitDecisionEvent("decision:before", beforeEvent);
13769
+ }
13770
+ const startedAt = Date.now();
13771
+ const result = await this.tryRunDecide({ ...merged, questions });
13772
+ obtained = result;
13773
+ // Null-prototype maps: a host id such as `__proto__` or `constructor`
13774
+ // must land as an own key, not fall into the prototype and vanish.
13775
+ const ownAnswers = Object.create(null);
13776
+ const hostAnswers = Object.create(null);
13777
+ if (result) {
13778
+ for (const [id, answer] of Object.entries(result.answers)) {
13779
+ const hostId = hostIdByWireId.get(id);
13780
+ if (hostId === undefined) {
13781
+ ownAnswers[id] = answer;
13782
+ }
13783
+ else {
13784
+ hostAnswers[hostId] = answer;
13785
+ }
13786
+ }
13787
+ }
13788
+ // Everything host-facing — the event and the hook — gets its own copy,
13789
+ // so a listener that edits an answer cannot change what NeuroLink's
13790
+ // consumer reads from the same call.
13791
+ const hostFacingResult = result ? structuredClone(result) : null;
13792
+ const afterEvent = {
13793
+ site,
13794
+ answers: structuredClone(ownAnswers),
13795
+ hostAnswers: structuredClone(hostAnswers),
13796
+ latencyMs: result?.latencyMs ?? Date.now() - startedAt,
13797
+ provider: result?.provider ??
13798
+ this.resolveDecisionProviderName(merged.provider, merged.credentials),
13799
+ model: result?.model,
13800
+ result: hostFacingResult,
13801
+ sessionId: merged.sessionId,
13802
+ requestId: merged.requestId,
13803
+ };
13804
+ this.emitDecisionEvent("decision:after", afterEvent);
13805
+ if (!result || !hostFacingResult) {
13806
+ return null;
13807
+ }
13808
+ if (hooks?.onAnswers) {
13809
+ try {
13810
+ await withTimeout(Promise.resolve(hooks.onAnswers({
13811
+ ...hookContext(),
13812
+ answers: structuredClone(hostAnswers),
13813
+ result: hostFacingResult,
13814
+ })), hookTimeoutMs, new Error(`onAnswers exceeded ${hookTimeoutMs}ms`));
13815
+ }
13816
+ catch (error) {
13817
+ logger.warn(`[Decision] onAnswers hook failed for site "${site}"`, {
13818
+ error: error instanceof Error ? error.message : String(error),
13819
+ });
13820
+ }
13821
+ }
13822
+ // The consumer's result: untouched when nothing was added, so the
13823
+ // no-hooks path returns the very object the provider produced.
13824
+ return hostQuestionCount === 0
13825
+ ? result
13826
+ : { ...result, answers: ownAnswers };
13827
+ }
13828
+ catch (error) {
13829
+ logger.warn(`[Decision] site "${site}" hook or event handling threw — ${obtained === undefined
13830
+ ? "deciding with NeuroLink's own questions"
13831
+ : "handing the consumer the answers already obtained"}`, { error: error instanceof Error ? error.message : String(error) });
13832
+ if (obtained === undefined) {
13833
+ return this.tryRunDecide(merged);
13834
+ }
13835
+ return obtained ? stripHostAnswers(obtained, hostIdByWireId) : null;
13836
+ }
13837
+ }
13838
+ /**
13839
+ * `decide()` proper. A request stamped with a site — every built-in
13840
+ * consumer's, and a host-wired consumer's — adds `decision.site` and
13841
+ * `decision.host_question_count` (the `host__N` ids the funnel namespaced)
13842
+ * to both spans, so a trace can tell a routing decision from a compaction
13843
+ * one and see how many questions the host rode along with.
13844
+ */
13845
+ async runDecide(options) {
13025
13846
  // SDK config counts as much as the environment: credentials given to the
13026
13847
  // constructor or to this call can configure a provider on their own.
13027
- const providerName = options.provider ??
13028
- resolveDefaultDecisionProvider(this.resolveCredentials(options.credentials));
13848
+ const providerName = this.resolveDecisionProviderName(options.provider, options.credentials);
13029
13849
  if (!providerName) {
13030
13850
  throw new Error(`No decision provider is configured. Set ${describeDecisionProviderKeys()} (in the environment or in \`credentials\`), or pass \`provider\` explicitly.`);
13031
13851
  }
13032
- const questionCount = Object.keys(options.questions).length;
13852
+ const questionIds = Object.keys(options.questions);
13853
+ const questionCount = questionIds.length;
13854
+ const stamped = options;
13855
+ const siteSpanAttributes = stamped.site
13856
+ ? {
13857
+ "decision.site": stamped.site,
13858
+ "decision.host_question_count": questionIds.filter(isHostDecisionKey).length,
13859
+ }
13860
+ : {};
13033
13861
  // Observability parity with generate()/stream(): an OTEL span for the
13034
13862
  // trace view, a serialized span for the in-process metrics aggregator,
13035
13863
  // and a cost figure. Without these a decision model is invisible — and
@@ -13045,6 +13873,7 @@ Current user's request: ${currentInput}`;
13045
13873
  "ai.provider": providerName,
13046
13874
  "decision.question_count": questionCount,
13047
13875
  "decision.images.count": options.images?.length ?? 0,
13876
+ ...siteSpanAttributes,
13048
13877
  },
13049
13878
  }, async (otelSpan) => {
13050
13879
  const { traceId, parentSpanId } = getActiveTraceContext();
@@ -13054,6 +13883,7 @@ Current user's request: ${currentInput}`;
13054
13883
  "ai.model": options.model ?? "",
13055
13884
  "decision.question_count": questionCount,
13056
13885
  "decision.images.count": options.images?.length ?? 0,
13886
+ ...siteSpanAttributes,
13057
13887
  }, parentSpanId, traceId);
13058
13888
  try {
13059
13889
  // enableMCP: false — a decision model has no tools and never will.
@@ -13140,8 +13970,22 @@ Current user's request: ${currentInput}`;
13140
13970
  * back joined. A batch that fails costs only its own answers, which every
13141
13971
  * consumer already reads as "no decision"; null comes back only when every
13142
13972
  * batch failed. `decide()` itself stays strict and refuses an over-cap request.
13973
+ *
13974
+ * A request stamped with a `site` — every built-in consumer stamps its own,
13975
+ * so a `RAGPipeline` or `ClassifierRouter` a host wired to this method gets
13976
+ * the same treatment as NeuroLink's internal ones — also runs the host's
13977
+ * `decisionHooks` and emits `decision:before` / `decision:after`.
13143
13978
  */
13144
13979
  async tryDecide(options) {
13980
+ return this.siteDecide(options);
13981
+ }
13982
+ /**
13983
+ * `tryDecide` without the site handling: the try/catch around the call.
13984
+ * Dispatches through `this.decide`, so a subclass override or an instance
13985
+ * patch on the public method sees every decision — the funnel's routing,
13986
+ * tool routing and compaction calls included, not only a host's own.
13987
+ */
13988
+ async tryRunDecide(options) {
13145
13989
  try {
13146
13990
  const result = await this.decideInBatches(options);
13147
13991
  if (logger.shouldLog("debug")) {
@@ -14273,6 +15117,14 @@ Current user's request: ${currentInput}`;
14273
15117
  cleanupErrors.push(err);
14274
15118
  logger.warn("[NeuroLink] Error stopping background work:", error);
14275
15119
  }
15120
+ // 0b. The memory condenser is a child instance this one created; it
15121
+ // goes with the host.
15122
+ try {
15123
+ await this.releaseMemoryCondenser();
15124
+ }
15125
+ catch (error) {
15126
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)));
15127
+ }
14276
15128
  // 1. Flush and shutdown OpenTelemetry
14277
15129
  try {
14278
15130
  logger.debug("[NeuroLink] Flushing and shutting down OpenTelemetry...");
@@ -14488,11 +15340,12 @@ Current user's request: ${currentInput}`;
14488
15340
  ?.summarizationProvider,
14489
15341
  summarizationModel: config?.summarizationModel ??
14490
15342
  this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
14491
- }, this.contextCompactorDeps());
15343
+ }, this.contextCompactorDeps({ sessionId }));
14492
15344
  // Use actual context window to determine target, not arbitrary heuristic
14493
15345
  const budgetInfo = checkContextBudget({
14494
15346
  provider: config?.provider || "openai",
14495
15347
  conversationMessages: messages,
15348
+ toolReplayMode: this.resolveToolReplayModeForBudget({}),
14496
15349
  });
14497
15350
  // Target 60% of available input tokens — leave room for new messages
14498
15351
  const targetTokens = Math.floor(budgetInfo.availableInputTokens * 0.6);
@@ -14518,6 +15371,7 @@ Current user's request: ${currentInput}`;
14518
15371
  provider: provider || "openai",
14519
15372
  model,
14520
15373
  conversationMessages: messages,
15374
+ toolReplayMode: this.resolveToolReplayModeForBudget({}),
14521
15375
  });
14522
15376
  return {
14523
15377
  estimatedInputTokens: budgetResult.estimatedInputTokens,
@@ -14542,6 +15396,7 @@ Current user's request: ${currentInput}`;
14542
15396
  provider: provider || "openai",
14543
15397
  model,
14544
15398
  conversationMessages: session.messages,
15399
+ toolReplayMode: this.resolveToolReplayModeForBudget({}),
14545
15400
  });
14546
15401
  return budgetResult.shouldCompact;
14547
15402
  }