@juspay/neurolink 12.47.14 → 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.
- package/CHANGELOG.md +3 -3
- package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
- package/dist/browser/neurolink.min.js +578 -565
- package/dist/cli/factories/commandFactory.d.ts +7 -0
- package/dist/cli/factories/commandFactory.js +26 -0
- package/dist/cli/loop/optionsSchema.js +9 -0
- package/dist/context/budgetChecker.js +54 -2
- package/dist/context/contextDecision.js +2 -0
- package/dist/context/toolPairRepair.d.ts +12 -0
- package/dist/context/toolPairRepair.js +29 -3
- package/dist/core/baseProvider.d.ts +92 -3
- package/dist/core/baseProvider.js +254 -10
- package/dist/core/conversationMemoryManager.js +17 -2
- package/dist/core/loopEngine.js +1 -1
- package/dist/core/modules/MessageBuilder.js +11 -3
- package/dist/core/modules/TelemetryHandler.d.ts +2 -2
- package/dist/core/modules/ToolsManager.d.ts +35 -0
- package/dist/core/modules/ToolsManager.js +159 -55
- package/dist/core/nativeGenerateLoop.d.ts +58 -1
- package/dist/core/nativeGenerateLoop.js +277 -3
- package/dist/core/redisConversationMemoryManager.js +10 -3
- package/dist/core/resolveRequestKind.d.ts +24 -4
- package/dist/core/resolveRequestKind.js +31 -4
- package/dist/core/toolExecutionRecorder.d.ts +6 -0
- package/dist/core/toolExecutionRecorder.js +24 -2
- package/dist/core/toolRoutingDecision.js +1 -0
- package/dist/factories/mediaHandlerCatalog.js +5 -0
- package/dist/factories/providerDescriptors.js +15 -0
- package/dist/files/fileTools.d.ts +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +9 -2
- package/dist/memory/condensationPrompt.d.ts +8 -0
- package/dist/memory/condensationPrompt.js +29 -0
- package/dist/memory/hippocampusInitializer.d.ts +14 -2
- package/dist/memory/hippocampusInitializer.js +64 -3
- package/dist/neurolink.d.ts +252 -3
- package/dist/neurolink.js +898 -43
- package/dist/providers/amazonBedrock/client.js +6 -0
- package/dist/providers/amazonSagemaker.js +14 -1
- package/dist/providers/anthropic/client.js +148 -19
- package/dist/providers/anthropic/loopAdapter.js +2 -2
- package/dist/providers/googleAiStudio/client.js +2 -1
- package/dist/providers/openaiChatCompletionsBase.js +103 -23
- package/dist/providers/systemOneDecision.d.ts +6 -0
- package/dist/providers/systemOneDecision.js +14 -63
- package/dist/providers/typesafe.d.ts +6 -0
- package/dist/providers/typesafe.js +6 -0
- package/dist/rag/retrieval/searchDecision.js +1 -0
- package/dist/routing/classifierRouter.js +6 -5
- package/dist/routing/classifierStrategies.js +8 -1
- package/dist/types/classifierRouter.d.ts +16 -3
- package/dist/types/common.d.ts +2 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/context.d.ts +8 -1
- package/dist/types/conversation.d.ts +87 -0
- package/dist/types/decision.d.ts +210 -1
- package/dist/types/decision.js +9 -1
- package/dist/types/dispatch.d.ts +11 -4
- package/dist/types/generate.d.ts +118 -54
- package/dist/types/loopEngine.d.ts +7 -2
- package/dist/types/memory.d.ts +177 -9
- package/dist/types/openaiCompatible.d.ts +11 -3
- package/dist/types/stream.d.ts +13 -16
- package/dist/types/stt.d.ts +58 -1
- package/dist/types/tools.d.ts +14 -0
- package/dist/types/tools.js +8 -0
- package/dist/types/tts.d.ts +108 -0
- package/dist/types/voice.d.ts +41 -4
- package/dist/utils/decisionAnswers.d.ts +31 -1
- package/dist/utils/decisionAnswers.js +114 -0
- package/dist/utils/decisionLimits.d.ts +39 -0
- package/dist/utils/decisionLimits.js +116 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +9 -0
- package/dist/utils/messageBuilder.d.ts +45 -1
- package/dist/utils/messageBuilder.js +393 -16
- package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
- package/dist/utils/multimodalOptionsBuilder.js +1 -0
- package/dist/utils/speechText.d.ts +41 -0
- package/dist/utils/speechText.js +364 -0
- package/dist/utils/toolResultStatus.d.ts +26 -0
- package/dist/utils/toolResultStatus.js +73 -0
- package/dist/utils/ttsProcessor.d.ts +29 -0
- package/dist/utils/ttsProcessor.js +84 -2
- package/dist/voice/index.d.ts +1 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/providers/ElevenLabsSTT.d.ts +42 -0
- package/dist/voice/providers/ElevenLabsSTT.js +351 -0
- package/dist/voice/providers/ElevenLabsTTS.d.ts +81 -1
- package/dist/voice/providers/ElevenLabsTTS.js +415 -74
- package/docs-site/static/search-index.json +48 -33
- package/package.json +5 -2
- package/dist/utils/toolEndEmitter.d.ts +0 -25
- 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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1004
|
-
//
|
|
1005
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
1819
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
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
|
-
* `
|
|
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.
|
|
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
|
-
|
|
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.
|
|
7516
|
-
// falls straight through to the generative router as before.
|
|
7517
|
-
|
|
7518
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|