@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
14,684 lines • 736 kB
JavaScript
/**
* NeuroLink - Unified AI Interface with Real MCP Tool Integration
*
* REDESIGNED FALLBACK CHAIN - NO CIRCULAR DEPENDENCIES
* Enhanced AI provider system with natural MCP tool access.
* Uses real MCP infrastructure for tool discovery and execution.
*/
// Load environment variables from .env file (critical for SDK usage).
// Shared with the CLI so the two implicit loads cannot drift apart again.
// Neither honoured DOTENV_CONFIG_PATH, and they had already drifted in how
// each suppressed dotenv's banner — one conditional, one unconditional.
import { loadProcessDotenv } from "./utils/dotenvBootstrap.js";
await loadProcessDotenv();
import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
import { AsyncLocalStorage } from "async_hooks";
import { EventEmitter } from "events";
import pLimit from "p-limit";
import { ErrorCategory, ErrorSeverity } from "./constants/enums.js";
import { CIRCUIT_BREAKER, CIRCUIT_BREAKER_RESET_MS, MEMORY_THRESHOLDS, NANOSECOND_TO_MS_DIVISOR, PERFORMANCE_THRESHOLDS, PROVIDER_TIMEOUTS, RETRY_ATTEMPTS, RETRY_DELAYS, TOOL_TIMEOUTS, } from "./constants/index.js";
import { checkContextBudget, resolveHistoryBudget, } from "./context/budgetChecker.js";
import { ContextCompactor } from "./context/contextCompactor.js";
import { InvalidToolInputError, NoSuchToolError, } from "./utils/generationErrors.js";
import { emergencyContentTruncation } from "./context/emergencyTruncation.js";
import { getContextOverflowProvider, isContextOverflowError, parseProviderOverflowDetails, } from "./context/errorDetection.js";
import { ContextBudgetExceededError } from "./context/errors.js";
import { repairToolPairs } from "./context/toolPairRepair.js";
import { SYSTEM_LIMITS, DEFAULT_TOOL_ROUTING_TIMEOUT_MS, MIN_RECOVERY_TURN_BUDGET_MS, } from "./core/constants.js";
import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
import { buildToolRoutingCatalog, buildRoutingQueryFromHistory, resolveToolRoutingExclusions, } from "./core/toolRouting.js";
import { ToolRoutingCache } from "./core/toolRoutingCache.js";
import { DEFAULT_RECENT_TURNS, KnowledgeGroundingEngine, } from "./knowledge/index.js";
import { AIProviderFactory } from "./core/factory.js";
import { describeDecisionProviderKeys, PROVIDER_ALIAS_INDEX, PROVIDER_DESCRIPTORS_BY_NAME, resolveDefaultDecisionProvider, } from "./factories/providerDescriptors.js";
import { MAX_PARALLEL_DECISION_BATCHES, mergeDecisionResults, splitDecisionRequest, } from "./utils/decisionBatches.js";
import { resolveRequestKind } from "./core/resolveRequestKind.js";
import { createToolEventPayload } from "./core/toolEvents.js";
import { ProviderFactory } from "./factories/providerFactory.js";
import { ProviderRegistry } from "./factories/providerRegistry.js";
import { FileReferenceRegistry } from "./files/fileReferenceRegistry.js";
import { createFileTools } from "./files/fileTools.js";
import { HITLManager } from "./hitl/hitlManager.js";
import { ToolCallBatcher } from "./mcp/batching/index.js";
// MCP Enhancement modules - wired into core execution path
import { ToolResultCache } from "./mcp/caching/index.js";
import { EnhancedToolDiscovery } from "./mcp/enhancedToolDiscovery.js";
import { ExternalServerManager } from "./mcp/externalServerManager.js";
import { McpOutputNormalizer, DEFAULT_MAX_MCP_OUTPUT_BYTES, DEFAULT_WARN_MCP_OUTPUT_BYTES, } from "./mcp/mcpOutputNormalizer.js";
import { createArtifactStore, resolveArtifactStorageType, } from "./artifacts/artifactStoreFactory.js";
import { bankArtifact as bankArtifactPayload, readArtifact as readBankedArtifact, } from "./artifacts/artifactBanking.js";
import { ToolRouter } from "./mcp/routing/index.js";
// Import direct tools server for automatic registration
import { directToolsServer } from "./mcp/servers/agent/directToolsServer.js";
import { inferAnnotations, isSafeToRetry } from "./mcp/toolAnnotations.js";
import { MCPToolRegistry } from "./mcp/toolRegistry.js";
// Dynamic argument resolution imports
import { resolveDynamicArgument } from "./dynamic/dynamicResolver.js";
import { initializeHippocampus } from "./memory/hippocampusInitializer.js";
import { createMemoryRetrievalTools } from "./memory/memoryRetrievalTools.js";
import { isSkillVisibleInScope } from "./skills/skillMatcher.js";
import { buildSkillActivationMessage } from "./skills/skillSessionTracker.js";
import { SkillsManager } from "./skills/skillsManager.js";
import { createSkillCallTools, createSkillTools } from "./skills/skillTools.js";
import { getMetricsAggregator, MetricsAggregator, } from "./observability/metricsAggregator.js";
import { AnalyticsService, calculateAdvancedCost, parseAnalyticsQualityScore, } from "./analytics/index.js";
import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, ModelAccessDeniedError, } from "./types/index.js";
import { resolveTerminalAgentMode } from "./agent/prompts/terminalAgentPrompt.js";
import { SpanSerializer } from "./observability/utils/spanSerializer.js";
import { withSpan } from "./telemetry/withSpan.js";
import { getActiveTraceContext } from "./telemetry/traceContext.js";
import { calculateCost } from "./utils/pricing.js";
import { flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, stampGuestRescueIdentity, } from "./services/server/ai/observability/instrumentation.js";
import { clearChecklistState, createChecklistTools, getChecklistState, resolveChecklistSessionId, } from "./agent/taskChecklist.js";
import { cancelDelegates as cancelBackgroundDelegates, collectDelegates as collectBackgroundDelegates, configureDelegation, createDelegationTools, spawnDelegate as spawnBackgroundDelegate, } from "./agent/backgroundDelegation.js";
import { awaitBackgroundCommand as awaitCommand, createBackgroundCommandTools, getBackgroundCommandStatus as getCommandStatus, killAllBackgroundCommands as killAllCommands, killBackgroundCommand as killCommand, readBackgroundCommandOutput as readCommandOutput, setBackgroundCommandPolicy as setCommandPolicy, startBackgroundCommand as startCommand, } from "./agent/backgroundCommands.js";
import { configureGitTools, createGitTools, runGitCommand as runGitArgs, } from "./agent/gitTools.js";
import { TaskManager } from "./tasks/taskManager.js";
import { createTaskTools } from "./tasks/tools/taskTools.js";
import { ATTR, spanJsonAttribute } from "./telemetry/attributes.js";
import { tracers } from "./telemetry/tracers.js";
import { getConversationMessages, storeConversationTurn, } from "./utils/conversationMemory.js";
// Enhanced error handling imports
import { CircuitBreaker, ERROR_CODES, ErrorFactory, isAbortError, isRetriableError, logStructuredError, NeuroLinkError, withRetry, withTimeout, } from "./utils/errorHandling.js";
import { hasLifecycleErrorFired, markLifecycleErrorFired, markLifecycleStreamCallbacksOwnedByBaseProvider, } from "./utils/lifecycleCallbacks.js";
import { interleaveTTSStream } from "./utils/ttsStream.js";
import { resolveLifecycleTimeoutMs } from "./utils/lifecycleTimeout.js";
import { cloneOptionsForCallIsolation } from "./utils/cloneOptions.js";
import { preserveLiveStreamAccessors } from "./utils/streamResultAccessors.js";
import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "./utils/json/coerce.js";
// Factory processing imports
import { createCleanStreamOptions, enhanceTextGenerationOptions, processFactoryOptions, processStreamingFactoryOptions, validateFactoryConfig, } from "./utils/factoryProcessing.js";
import { logger, mcpLogger } from "./utils/logger.js";
import { redactUrlCredentials, safeDebugSerialize, sanitizeRecord, stringifyContentSafe, } from "./utils/logSanitize.js";
import { extractMcpErrorText } from "./utils/mcpErrorText.js";
import { createCustomToolServerInfo, detectCategory, } from "./utils/mcpDefaults.js";
import { ExternalMcpToolNotFoundError, rankToolNameCandidates, resolveToolName, } from "./utils/toolCallRepair.js";
import { resolveModel } from "./utils/modelAliasResolver.js";
// Import orchestration components
import { ModelRouter } from "./utils/modelRouter.js";
import { getBestProvider } from "./utils/providerUtils.js";
import { isZodSchema, convertZodToJsonSchema, } from "./utils/schemaConversion.js";
import { resolveToolPolicy } from "./tools/toolPolicy.js";
import { applyToolGate } from "./tools/toolGate.js";
import { directAgentTools } from "./agent/directTools.js";
import { bindFileToolRoots, boundFileToolRoots, resolveFileToolRootPolicy, } from "./utils/fileToolRoots.js";
import { BinaryTaskClassifier } from "./utils/taskClassifier.js";
// Tool detection and execution imports
// Transformation utilities
import { extractToolNames, optimizeToolForCollection, transformAvailableTools, transformParamsForLogging, transformToolExecutionsForMCP, transformToolsForMCP, transformToolsToDescriptions, transformToolsToExpectedFormat, } from "./utils/transformationUtils.js";
import { toToolExecutionRecords } from "./core/toolExecutionRecorder.js";
import { isNonNullObject } from "./utils/typeUtils.js";
import { getWorkflow } from "./workflow/core/workflowRegistry.js";
import { runWorkflow } from "./workflow/core/workflowRunner.js";
import { ModelPool, classifyProviderError } from "./routing/index.js";
import { ClassifierRouter } from "./routing/classifierRouter.js";
import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, looksLikeModelNotFound as sharedLooksLikeModelNotFound, isNonRetryableProviderError as sharedIsNonRetryableProviderError, isNonRetryableForPool as sharedIsNonRetryableForPool, } from "./utils/providerErrorClassification.js";
import { getErrorStatusCode } from "./utils/providerRetry.js";
import { detectAndRedactPII } from "./utils/piiDetector.js";
import { validateResponse } from "./utils/responseValidator.js";
/**
* NL-002: Classify MCP error messages into categories for AI disambiguation.
* Returns a human-readable error category based on error message content.
*/
function classifyMcpErrorMessage(text) {
const lower = text.toLowerCase();
if (lower.includes("not found") ||
lower.includes("404") ||
lower.includes("does not exist") ||
lower.includes("no such")) {
return "not_found";
}
if (lower.includes("permission") ||
lower.includes("forbidden") ||
lower.includes("403") ||
lower.includes("unauthorized") ||
lower.includes("401") ||
lower.includes("access denied")) {
return "permission_denied";
}
if (lower.includes("timeout") ||
lower.includes("timed out") ||
lower.includes("deadline exceeded")) {
return "timeout";
}
if (lower.includes("rate limit") ||
lower.includes("429") ||
lower.includes("too many requests") ||
lower.includes("throttl")) {
return "rate_limited";
}
if (lower.includes("invalid") ||
lower.includes("validation") ||
lower.includes("bad request") ||
lower.includes("400")) {
return "validation_error";
}
return "unknown";
}
function mcpCategoryToErrorCategory(mcpCategory) {
switch (mcpCategory) {
case "not_found":
return ErrorCategory.VALIDATION;
case "permission_denied":
return ErrorCategory.PERMISSION;
case "timeout":
return ErrorCategory.TIMEOUT;
case "rate_limited":
return ErrorCategory.RESOURCE;
case "validation_error":
return ErrorCategory.VALIDATION;
case "unknown":
return ErrorCategory.EXECUTION;
}
}
/**
* Check if an error is a non-retryable provider error that should immediately
* stop the retry/fallback chain. These errors represent permanent failures
* (e.g., model not found, authentication failed) where retrying with the
* same configuration will never succeed.
*
* This prevents wasting tokens and latency on guaranteed-to-fail retries.
* For example, a NOT_FOUND error for a model causes 6 retries of a 418KB
* message, wasting ~628,000 tokens and adding 10+ seconds of latency.
*
* Delegates to the shared utility in utils/providerErrorClassification.ts so
* that modelPool.ts (classifyProviderError) and this function stay in sync.
*/
const looksLikeModelAccessDenied = sharedLooksLikeModelAccessDenied;
const isNonRetryableProviderError = sharedIsNonRetryableProviderError;
/**
* Pool-scoped variant: model-not-found is a fact about one member's model, not
* about the request, so it must not stop the whole pool. Used only at the two
* ModelPool catch sites; every other path keeps the general contract.
*/
const isNonRetryableForPool = sharedIsNonRetryableForPool;
/**
* NeuroLink - Universal AI Development Platform
*
* Main SDK class providing unified access to many AI providers with enterprise features:
* - Multi-provider support (OpenAI, Anthropic, Google AI Studio, Google Vertex, AWS Bedrock, etc.)
* - MCP (Model Context Protocol) tool integration — connect any MCP-compliant server
* - Human-in-the-Loop (HITL) security workflows for regulated industries
* - Redis-based conversation memory and persistence
* - Enterprise middleware system for monitoring and control
* - Automatic provider fallback and retry logic
* - Streaming with real-time token delivery
* - Multimodal support (text, images, PDFs, CSV)
*
* @category Core
*
* @example Basic usage
* ```typescript
* import { NeuroLink } from '@juspay/neurolink';
*
* const neurolink = new NeuroLink();
*
* const result = await neurolink.generate({
* input: { text: 'Explain quantum computing' },
* provider: 'vertex',
* model: 'gemini-3-flash-preview'
* });
*
* console.log(result.content);
* ```
*
* @example With HITL security
* ```typescript
* const neurolink = new NeuroLink({
* hitl: {
* enabled: true,
* requireApproval: ['writeFile', 'executeCode'],
* confidenceThreshold: 0.85
* }
* });
* ```
*
* @example With Redis memory
* ```typescript
* const neurolink = new NeuroLink({
* conversationMemory: {
* enabled: true,
* redis: {
* url: 'redis://localhost:6379'
* }
* }
* });
* ```
*
* @example With MCP tools
* ```typescript
* const neurolink = new NeuroLink();
*
* // Discover available tools
* const tools = await neurolink.getAvailableTools();
*
* // Use tools in generation
* const result = await neurolink.generate({
* input: { text: 'Read the README.md file' },
* tools: ['readFile']
* });
* ```
*
* @see {@link GenerateOptions} for generation options
* @see {@link StreamOptions} for streaming options
* @see {@link NeurolinkConstructorConfig} for configuration options
* @since 1.0.0
*/
/**
* Module-level AsyncLocalStorage for per-request metrics trace context.
* Eliminates the race condition where overlapping generate/stream calls on the
* same NeuroLink instance would clobber each other's trace context.
*/
const metricsTraceContextStorage = new AsyncLocalStorage();
/**
* Curator P2-4 dedup (concurrency-safe): native providers emit
* `generation:end` on the shared SDK emitter. We attach a fresh
* mutable `dedupContext` object directly to the per-call
* `StreamOptions` (under `_streamDedupContext`) so each stream gets
* its own instance — concurrent streams have different option objects
* and therefore different contexts, so they cannot interfere.
*
* Native provider emit sites read `options._streamDedupContext` and
* flip `.providerEmitted = true` before emitting; the orchestration's
* finally block reads the same closed-over reference and skips its
* own emit when the flag is set.
*
* This avoids the AsyncLocalStorage approach which doesn't reliably
* propagate through async-generator yield boundaries when iteration
* happens from outside the original `run()` scope (e.g. when the
* consumer drives `for await of result.stream` after `sdk.stream(...)`
* returns).
*/
export const STREAM_DEDUP_CONTEXT_KEY = "_streamDedupContext";
/**
* Native providers call this from their `generation:end` emit sites,
* passing the same `options` object they received. Safe no-op when
* the field isn't set.
*/
export function markStreamProviderEmittedGenerationEnd(options) {
const ctx = options?._streamDedupContext;
if (ctx) {
ctx.providerEmitted = true;
}
}
/**
* Symbol-based brand for cross-module identification without circular imports.
*
* Provider constructors receive `sdk?: unknown` (the factory layer's
* contract). Rather than duck-typing via `"getInMemoryServers" in sdk`,
* use `isNeuroLink(value)` from this module to do a brand check —
* survives minification AND doesn't rely on method-name stability.
*/
export const NEUROLINK_BRAND = Symbol.for("@juspay/neurolink/sdk-brand");
/**
* True when a provider needs tools described in the prompt rather than
* passed via the API's native tool-calling parameter (ollama/openrouter
* are model-dependent; huggingface is deployment-dependent; the
* image/embedding providers don't support tool calling at all). Only these
* still receive the full tool listing in the system prompt on the generate
* path, where no provider instance exists yet to ask directly; every other
* provider gets tool definitions natively via its `tools` parameter, so
* repeating them in the prompt was pure token duplication. The stream path
* asks the provider instance (`provider.supportsTools()`) instead of this
* check. Derived from ProviderDescriptor.toolSupport instead of a
* hand-maintained Set — see PROVIDER_DESCRIPTORS in
* src/lib/factories/providerDescriptors.ts for the underlying data;
* BaseProvider resolves its own runtime default through MODEL_REGISTRY's
* `modelSupports()` facade.
*/
function isPromptOnlyToolProvider(providerName) {
return ProviderFactory.getDescriptor(providerName)?.toolSupport !== "native";
}
/**
* Type-guard for opaque values that should be a {@link NeuroLink} instance.
*
* Designed for the provider-factory boundary where TS can't carry the type
* through `UnknownRecord` without forcing every caller into a circular
* dependency. Cheap to call and unaffected by minification.
*/
export function isNeuroLink(value) {
return (typeof value === "object" &&
value !== null &&
value[NEUROLINK_BRAND] === true);
}
/**
* Create a Node {@link EventEmitter} exposed through the typed-emitter
* surface. The runtime object is a plain EventEmitter — the typed view only
* narrows the event-name/payload relationship for callers, so the widening
* hop through `unknown` is contained here once.
*/
function createTypedEmitter() {
const emitter = new EventEmitter();
return emitter;
}
/** Monotonic source for {@link NeuroLink.logInstanceId}. Process-local by design. */
let neuroLinkInstanceCounter = 0;
export class NeuroLink {
/** @internal Brand for cross-module identification — see {@link isNeuroLink}. */
[NEUROLINK_BRAND] = true;
mcpInitialized = false;
mcpSkipped = false;
mcpInitPromise = null;
emitter = createTypedEmitter();
/**
* Process-unique id used to attribute this instance's log events. The SDK
* entry points run their bodies inside `logger.runInInstanceScope(this.logInstanceId, …)`,
* which is what lets a worker's log bridge receive only the worker's own
* events instead of everything in the process.
*/
logInstanceId = `nl-${++neuroLinkInstanceCounter}`;
/**
* Detach callbacks for scoped log bridges of workers this instance created
* via {@link createWorkerInstance} with `onLog`, that have not yet been
* removed by the worker's own `dispose()`. The scoped-emitter registry
* lives on the process-global logger, keyed by the WORKER's id — not this
* host's — so it survives this host being garbage collected. If a caller
* disposes the host but forgets an individual worker (the host is the
* long-lived object in most delegation patterns; the worker the
* short-lived one), the bridge — and everything its `onLog` closes over —
* would otherwise stay registered for the life of the process. `dispose()`
* sweeps whatever is still here so host cleanup is sufficient on its own,
* matching the pre-scoped-routing behavior where a worker's forward
* listener lived on the host's own emitter and was removed by
* `this.emitter.removeAllListeners()`.
*/
ownedWorkerLogBridgeDetachers = new Set();
// TaskManager — lazy-initialized on first access via `this.tasks`
_taskManager;
_taskManagerConfig;
toolRegistry;
autoDiscoveredServerInfos = [];
// External MCP server management
externalServerManager;
// Cache for available tools to improve performance
toolCache = null;
toolCacheDuration;
// NL-004: Model alias/deprecation configuration
modelAliasConfig;
// Compaction watermark: prevents re-triggering compaction on already-compacted messages
// Per-session map to avoid cross-session pollution in server mode
lastCompactionMessageCount = new Map();
/** Extract sessionId from options context for compaction watermark keying */
getCompactionSessionId(options) {
return (options.context
?.sessionId || "__default__");
}
// MCP Enhancement modules - wired into core execution path
mcpToolResultCache;
mcpToolRouter;
mcpToolBatcher;
mcpEnhancedDiscovery;
mcpToolMiddlewares = [];
/** Artifact store for externalized MCP tool outputs and banked payloads. */
mcpArtifactStore;
/** `artifacts` constructor config — consulted whenever a store is created. */
artifactsConfig;
/**
* True when this instance built `mcpArtifactStore` itself (local or Redis
* from config / STORAGE_TYPE). NeuroLink closes only stores it built — on
* replacement and on shutdown; a store handed in through `artifacts.store`
* or `setArtifactStore()` is the caller's to close.
*/
ownsArtifactStore = false;
/** Normalizer settings, kept so `setArtifactStore()` can rebuild it. */
mcpOutputNormalizerConfig;
_disableToolCacheForCurrentRequest = false;
/**
* (toolName + args) keys already served during the CURRENT request.
* A repeat occurrence within one request bypasses the tool-result cache:
* when the model deliberately re-calls a tool with identical args in the
* same turn (a counter, a poll, "check status again"), it wants fresh
* state — serving the memoized first result silently freezes stateful
* tools (observed live: a 5-round counter loop executed once). Cross-
* request dedup — BZ-664's actual goal — is untouched: the first
* occurrence in a request may still be served from cache. Request-scoped
* like _disableToolCacheForCurrentRequest above (assigned a fresh Set at
* request start so the router's save/restore-by-reference pattern works).
*/
_toolCacheKeysServedThisRequest = new Set();
/** True only while a generate()/stream() turn is executing — the
* repeat-call cache bypass applies inside a turn; direct executeTool
* calls keep full BZ-664 cache semantics. */
_generationTurnActive = false;
mcpEnhancementsConfig;
// Enhanced error handling support
toolCircuitBreakers = new Map();
toolExecutionMetrics = new Map();
currentStreamToolExecutions = [];
toolExecutionHistory = [];
activeToolExecutions = new Map();
/**
* Helper method to emit tool end event in a consistent way
* Used by executeTool in both success and error paths
* @param toolName - Name of the tool
* @param startTime - Timestamp when tool execution started
* @param success - Whether the tool execution was successful
* @param result - The result of the tool execution (optional)
* @param error - The error if execution failed (optional)
*/
emitToolEndEvent(toolName, startTime, success, result, error, executionId) {
// Emit tool end event (NeuroLink format - enhanced with result/error)
// Serialize error to string for consumer compatibility (event listeners
// commonly check `typeof event.error === "string"`).
this.emitter.emit("tool:end", createToolEventPayload(toolName, {
responseTime: Date.now() - startTime,
success,
timestamp: Date.now(),
result,
error: error ? error.message : undefined,
executionId,
}));
}
// Conversation memory support
conversationMemory;
conversationMemoryNeedsInit = false;
conversationMemoryConfig;
// Pre-call tool routing: instance-level config from the constructor.
// The server catalog inside it can be supplied/replaced later via
// setToolRoutingServers() for hosts that register tools after construction.
toolRoutingConfig;
// Lazy-initialized routing decision cache (ITEM C). Created on first use so
// instances that don't use routing pay no overhead.
toolRoutingCacheInstance;
// Persisted vector cache for the L2 embedding fast-path (ITEM B). Populated
// on the first turn and reused across subsequent turns so tool embedding
// vectors are computed once. Cleared when the catalog changes via
// setToolRoutingServers() so stale vectors are never reused.
toolRoutingVectorCache;
// Knowledge grounding: lexical-first host-supplied retrieval engine. Built
// once from constructor config and undefined unless grounding is enabled.
knowledgeGroundingEngine;
// Opt-in tool-signature deduplication config.
toolDedupConfig;
toolsConfig;
/** Session-scoped pins of tools discovered via search_tools (tools.discovery mode). */
discoveryPins = new Map();
// Add orchestration property
enableOrchestration;
// Authentication provider for secure access control
authProvider;
pendingAuthConfig;
authInitPromise;
// Per-provider credential overrides (instance-level default)
credentials;
// Curator P2-3: instance-level fallback policy. Read by
// runWithFallbackOrchestration on model-access-denied.
fallbackConfig = {};
// ModelPool: opt-in multi-provider failover with per-member cooldown.
// Built once from config.modelPool in the constructor; null when not configured.
modelPool;
// RequestRouter: pluggable pre-call provider/model selector.
// Stored directly from config.requestRouter; null when not configured.
requestRouter;
// ClassifierRouter: opt-in "classify → pick model + tools" pre-call router.
// Built from config.classifierRouter; null when not configured.
classifierRouter;
// Tuning for the decision-driven relevance stage of context compaction.
// Undefined = the stage's own defaults; the stage itself is gated on a
// decision provider being configured, not on this.
contextRelevanceOptions;
/**
* Merge instance-level credentials with per-call credentials.
*
* Semantics: **deep merge at the provider level.** For each provider key
* present in both `this.credentials` and `callCredentials`, the per-call
* fields are merged ON TOP of the instance-level fields, so fields not
* mentioned in the per-call slice are preserved.
*
* Example:
* ```
* instance: { openai: { apiKey: "key1", baseURL: "url1" } }
* per-call: { openai: { apiKey: "key2" } }
* merged: { openai: { apiKey: "key2", baseURL: "url1" } } // baseURL preserved
* ```
*
* Providers present only in one source are carried through unchanged.
* Unrelated providers (not overridden in callCredentials) are carried through
* from instance credentials unchanged.
*/
resolveCredentials(callCredentials) {
if (!this.credentials && !callCredentials) {
return undefined;
}
if (!this.credentials) {
return callCredentials;
}
if (!callCredentials) {
return this.credentials;
}
// Per-provider deep merge: for each provider key in the per-call
// override, merge its fields on top of the instance-level slice so
// individual fields (e.g. baseURL) are preserved when only apiKey
// is overridden per-call.
const merged = { ...this.credentials };
for (const key of Object.keys(callCredentials)) {
const instanceSlice = this.credentials[key];
const callSlice = callCredentials[key];
if (instanceSlice &&
callSlice &&
typeof instanceSlice === "object" &&
typeof callSlice === "object") {
merged[key] = {
...instanceSlice,
...callSlice,
};
}
else {
merged[key] = callSlice ?? instanceSlice;
}
}
return merged;
}
// HITL (Human-in-the-Loop) support
hitlManager;
// Accumulated cost in USD across all generate() calls on this instance
_sessionCostUsd = 0;
// File Reference Registry for lazy on-demand file processing
fileRegistry;
// Cached file tools to avoid redundant createFileTools() calls per generate/stream
cachedFileTools = null;
// Memory instance and config
memoryInstance;
memorySDKConfig;
// Skills subsystem — lazily initialized manager + instance config.
// `undefined` = not yet attempted, `null` = init failed (stay disabled).
skillsManagerInstance;
skillsConfig;
/**
* Extract and set Langfuse context from options with proper async scoping
*/
async setLangfuseContextFromOptions(options, callback) {
if (options.context &&
typeof options.context === "object" &&
options.context !== null) {
let callbackExecuted = false;
try {
const ctx = options.context;
// Trigger context scoping if any meaningful Langfuse field is present
if (ctx.userId ||
ctx.sessionId ||
ctx.conversationId ||
ctx.requestId ||
ctx.traceName ||
ctx.metadata) {
// Build customAttributes from top-level metadata string/number/boolean fields
let customAttributes;
if (ctx.metadata && typeof ctx.metadata === "object") {
const metaObj = ctx.metadata;
const attrs = {};
for (const [k, v] of Object.entries(metaObj)) {
if (typeof v === "string" ||
typeof v === "number" ||
typeof v === "boolean") {
attrs[k] = v;
}
}
if (Object.keys(attrs).length > 0) {
customAttributes = attrs;
}
}
return await new Promise((resolve, reject) => {
setLangfuseContext({
userId: typeof ctx.userId === "string" ? ctx.userId : null,
sessionId: typeof ctx.sessionId === "string" ? ctx.sessionId : null,
conversationId: typeof ctx.conversationId === "string"
? ctx.conversationId
: null,
requestId: typeof ctx.requestId === "string" ? ctx.requestId : null,
traceName: typeof ctx.traceName === "string" ? ctx.traceName : null,
metadata: ctx.metadata && typeof ctx.metadata === "object"
? ctx.metadata
: null,
...(customAttributes !== undefined && { customAttributes }),
}, async () => {
try {
callbackExecuted = true;
const result = await callback();
resolve(result);
}
catch (error) {
reject(error);
}
});
});
}
}
catch (error) {
if (callbackExecuted) {
// Callback was executed inside Langfuse context but failed — do NOT retry
// Re-throw to avoid double API calls and preserve error context
throw error;
}
// Langfuse context setup itself failed — graceful degradation, run without context
logger.warn("Failed to set Langfuse context from options", {
error: error instanceof Error ? error.message : String(error),
});
}
}
return await callback();
}
createMetricsTraceContext() {
// Attempt to reuse the active OTel trace context so Pipeline B spans
// land in the same Langfuse trace as Pipeline A spans.
const activeSpan = trace.getSpan(context.active());
if (activeSpan) {
const spanCtx = activeSpan.spanContext();
// Only use the OTel context if it has a valid trace ID.
// parentSpanId stores the active span's ID as a parent reference;
// each Pipeline B span must generate its own unique spanId to comply
// with the OTel/W3C requirement that spanIds are unique per trace.
if (spanCtx.traceId &&
spanCtx.traceId !== "00000000000000000000000000000000") {
return {
traceId: spanCtx.traceId,
parentSpanId: spanCtx.spanId,
};
}
}
// Fallback: no active OTel context (e.g. standalone Pipeline B usage)
return {
traceId: crypto.randomUUID().replace(/-/g, ""),
parentSpanId: crypto.randomUUID().replace(/-/g, "").substring(0, 16),
};
}
enforceSessionBudget(maxBudgetUsd) {
if (maxBudgetUsd === undefined ||
maxBudgetUsd <= 0 ||
this._sessionCostUsd < maxBudgetUsd) {
return;
}
throw new NeuroLinkError({
code: "SESSION_BUDGET_EXCEEDED",
message: `Session budget exceeded: spent $${this._sessionCostUsd.toFixed(4)} of $${maxBudgetUsd.toFixed(4)} limit`,
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.HIGH,
retriable: false,
context: {
spent: this._sessionCostUsd,
limit: maxBudgetUsd,
},
});
}
assertInputText(text, message) {
if (!text || typeof text !== "string") {
throw new Error(message);
}
}
async applyAuthenticatedRequestContext(options) {
if (options.auth?.token) {
const { AuthError } = await import("./auth/errors.js");
await this.ensureAuthProvider();
if (!this.authProvider) {
throw AuthError.create("PROVIDER_ERROR", "No auth provider configured. Set auth in constructor or via setAuthProvider() before using auth: { token }.");
}
let authResult;
try {
authResult = await withTimeout(this.authProvider.authenticateToken(options.auth.token), 5000, AuthError.create("PROVIDER_ERROR", "Auth token validation timed out after 5000ms"));
}
catch (error) {
if (error instanceof Error &&
"feature" in error &&
error.feature === "Auth") {
throw error;
}
throw AuthError.create("PROVIDER_ERROR", `Auth token validation failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (!authResult.valid) {
throw AuthError.create("INVALID_TOKEN", authResult.error || "Token validation failed");
}
if (!authResult.user) {
throw AuthError.create("INVALID_TOKEN", "Token validated but no user identity returned");
}
if (!authResult.user.id) {
throw AuthError.create("INVALID_TOKEN", "Token validated but user identity missing required 'id' field");
}
options.context = {
...(options.context || {}),
userId: authResult.user.id,
userEmail: authResult.user.email,
userRoles: authResult.user.roles,
};
}
if (!options.requestContext) {
return;
}
const tokenDerivedFields = options.auth?.token && this.authProvider
? {
userId: options.context?.userId,
userEmail: options.context?.userEmail,
userRoles: options.context?.userRoles,
}
: {};
options.context = {
...(options.context || {}),
...options.requestContext,
...tokenDerivedFields,
};
}
applyGenerateLifecycleMiddleware(options) {
if (!options.onFinish && !options.onError) {
return;
}
options.middleware = {
...options.middleware,
middlewareConfig: {
...options.middleware?.middlewareConfig,
lifecycle: {
...options.middleware?.middlewareConfig?.lifecycle,
enabled: true,
config: {
...options.middleware?.middlewareConfig?.lifecycle?.config,
...(options.onFinish !== undefined
? { onFinish: options.onFinish }
: {}),
...(options.onError !== undefined
? { onError: options.onError }
: {}),
},
},
},
};
}
applyStreamLifecycleMiddleware(options) {
if (!options.onFinish && !options.onError && !options.onChunk) {
return;
}
const config = {
...options.middleware?.middlewareConfig?.lifecycle?.config,
...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
...(options.onError !== undefined ? { onError: options.onError } : {}),
...(options.onChunk !== undefined ? { onChunk: options.onChunk } : {}),
};
// BaseProvider.wrapStreamWithLifecycleCallbacks unconditionally wraps
// every provider's stream() result and fires onChunk/onFinish from
// there, reliably, for this exact config object. Stamp it so the
// model-level lifecycle middleware (builtin/lifecycle.ts) skips its own
// onChunk/onFinish firing for it instead of delivering each one twice.
// See markLifecycleStreamCallbacksOwnedByBaseProvider's doc comment for
// why generate()'s config below is never stamped this way.
markLifecycleStreamCallbacksOwnedByBaseProvider(config);
options.middleware = {
...options.middleware,
middlewareConfig: {
...options.middleware?.middlewareConfig,
lifecycle: {
...options.middleware?.middlewareConfig?.lifecycle,
enabled: true,
config,
},
},
};
}
initializeMemoryConfig() {
const memory = this.conversationMemoryConfig?.conversationMemory?.memory;
if (!memory?.enabled) {
return false;
}
this.memorySDKConfig = memory;
return true;
}
/**
* Lazy initialization for memory — called during generate/stream.
*/
ensureMemoryReady() {
if (this.memoryInstance !== undefined) {
return this.memoryInstance;
}
if (!this.initializeMemoryConfig()) {
this.memoryInstance = null;
return null;
}
if (!this.memorySDKConfig) {
this.memoryInstance = null;
return null;
}
this.memoryInstance = initializeHippocampus(this.memorySDKConfig);
return this.memoryInstance;
}
/**
* Context storage for tool execution
* This context will be merged with any runtime context passed by the AI model
*/
toolExecutionContext;
/**
* Set when registerAgentTool() has registered at least one delegation
* tool — gates the per-turn delegation scope in generate().
*/
hasAgentTools = false;
/**
* Tools registered with `cacheable: false` — their results are never served
* from the tool-result cache. A tool whose answer depends on live state (a
* checklist, a work queue) would otherwise replay its first answer for the
* whole TTL to every caller passing the same arguments.
*/
uncacheableTools = new Set();
/** Set once registerTaskTools() has registered the checklist toolset. */
hasTaskChecklistTools = false;
/** Set once registerDelegationTools() has registered the delegation toolset. */
hasBackgroundDelegationTools = false;
/** Set once registerBackgroundCommandTools() has registered the command toolset. */
hasBackgroundCommandTools = false;
/** Set once registerGitTools() has registered the read-only git toolset. */
hasGitTools = false;
/**
* Set once `retrieve_context` has been registered. The constructor skips
* registration when neither Redis nor an artifact store exists; banking can
* create a store later, and this keeps that second attempt from
* re-registering the tool on instances that already have it.
*/
retrieveContextRegistered = false;
/**
* Creates a new NeuroLink instance for AI text generation with MCP tool integration.
*
* @param config - Optional configuration object
* @param config.conversationMemory - Configuration for conversation memory features
* @param config.conversationMemory.enabled - Whether to enable conversation memory (default: false)
* @param config.conversationMemory.maxSessions - Maximum number of concurrent sessions (default: 100)
* @param config.conversationMemory.maxTurnsPerSession - Maximum conversation turns per session (default: 50)
* @param config.enableOrchestration - Whether to enable smart model orchestration (default: false)
* @param config.hitl - Configuration for Human-in-the-Loop safety features
* @param config.hitl.enabled - Whether to enable HITL tool confirmation (default: false)
* @param config.hitl.dangerousActions - Keywords that trigger confirmation (default: ['delete', 'remove', 'drop'])
* @param config.hitl.timeout - Confirmation timeout in milliseconds (default: 30000)
* @param config.hitl.allowArgumentModification - Allow users to modify tool parameters (default: true)
* @param config.toolRegistry - Optional tool registry instance for advanced use cases (default: new MCPToolRegistry())
*
* @example
* ```typescript
* // Basic usage
* const neurolink = new NeuroLink();
*
* // With conversation memory
* const neurolink = new NeuroLink({
* conversationMemory: {
* enabled: true,
* maxSessions: 50,
* maxTurnsPerSession: 20
* }
* });
*
* // With orchestration enabled
* const neurolink = new NeuroLink({
* enableOrchestration: true
* });
*
* // With HITL safety features
* const neurolink = new NeuroLink({
* hitl: {
* enabled: true,
* dangerousActions: ['delete', 'remove', 'drop', 'truncate'],
* timeout: 30000,
* allowArgumentModification: true
* }
* });
* ```
*
* @throws {Error} When provider registry setup fails
* @throws {Error} When conversation memory initialization fails (if enabled)
* @throws {Error} When external server manager initialization fails
* @throws {Error} When HITL configuration is invalid (if enabled)
*/
observabilityConfig;
metricsAggregator = new MetricsAggregator();
analyticsService;
/**
* Per-request metrics trace context backed by AsyncLocalStorage.
* Safe for concurrent requests on the same SDK instance.
* Context is set via metricsTraceContextStorage.run() in generate/stream.
*/
get _metricsTraceContext() {
return metricsTraceContextStorage.getStore() ?? null;
}
constructor(config) {
if (config?.toolRegistry) {
// A supplied registry (a host's, shared with a worker) keeps its owner's
// roots; per-call executeTool() binds this instance's policy anyway.
this.toolRegistry = config.toolRegistry;
}
else {
this.toolRegistry = new MCPToolRegistry();
this.toolRegistry.setFileToolRootResolver(() => this.resolveFileToolRoots(undefined));
}
this.fileRegistry = new FileReferenceRegistry();
this.observabilityConfig = config?.observability;
this.analyticsService = new AnalyticsService();
// Initialize orchestration setting
this.enableOrchestration = config?.enableOrchestration ?? false;
// NL-004: Initialize model alias configuration
if (config?.modelAliasConfig) {
this.modelAliasConfig = config.modelAliasConfig;
}
// Curator P2-3: capture fallback policy. Per-call options can still
// override, but these are the instance-level defaults.
if (config?.providerFallback) {
this.fallbackConfig.providerFallback = config.providerFallback;
}
if (config?.modelChain) {
this.fallbackConfig.modelChain = config.modelChain;
}
if (config?.toolRouting) {
// Shallow-clone so setToolRoutingServers() mutating this.toolRoutingConfig
// can't leak into the caller's config object, which may be shared across
// multiple NeuroLink instances.
this.toolRoutingConfig = { ...config.toolRouting };
}
const knowledgeGroundingConfig = config?.knowledgeGrounding;
if (knowledgeGroundingConfig?.enabled) {
if (Array.isArray(knowledgeGroundingConfig.sources) &&
knowledgeGroundingConfig.sources.length > 0) {
// The engine builds its immutable index asynchronously; eligible
// generate() and stream() calls await readiness before retrieval.
this.knowledgeGroundingEngine = new KnowledgeGroundingEngine(knowledgeGroundingConfig);
}
else {
logger.warn("[KnowledgeGrounding] enabled but no sources were provided; grounding disabled for this instance");
}
}
if (config?.toolDedup) {
this.toolDedupConfig = { ...config.toolDedup };
}
if (config?.tools) {
// Shallow-clone so later caller mutations of the config object don't
// change this instance's tool policy mid-flight.
this.toolsConfig = { ...config.tools };
}
// ModelPool: build one instance from config; null when not configured.
this.modelPool = config?.modelPool ? new ModelPool(config.modelPool) : null;
// RequestRouter: store the host-supplied function; null when not configured.
this.requestRouter = config?.requestRouter ?? null;
// Tuning for the relevance stage of compaction. Storing it (rather than
// reading config at each call site) keeps compactor construction uniform
// across the five places that build one.
this.contextRelevanceOptions = config?.contextRelevance;
// ClassifierRouter: opt-in. The LLM strategy reuses this instance's
// generate() (marked so it never recursively re-routes). Fails open.
this.classifierRouter = config?.classifierRouter?.enabled
? new ClassifierRouter(config.classifierRouter, {
generate: (genOptions) => this.generate({
...genOptions,
}),
// Fail-open by construction: tryDecide returns null rather than
// throwing, so an absent or broken decision provider leaves routing
// exactly as it was.
decide: (decideOptions) => this.tryDecide(decideOptions),
// Read lazily: `this.credentials` is assigned later in the constructor.
hasDecisionProvider: () => resolveDefaultDecisionProvider(this.credentials) !== undefined,
logger: {
debug: (message, meta) => logger.debug(message, meta),
warn: (message, meta) => logger.warn(message, meta),
},
})
: null;
logger.setEventEmitter(this.emitter);
// Read tool cache duration from environment variables, with a default
const cacheDurationEnv = process.env.NEUROLINK_TOOL_CACHE_DURATION;
this.toolCacheDuration = cacheDurationEnv
? parseInt(cacheDurationEnv, 10)
: 20000;
const constructorStartTime = Date.now();
const constructorHrTimeStart = process.hrtime.bigint();
const constructorId = `neurolink-constructor-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
this.initializeProviderRegistry(constructorId, constructorStartTime, constructorHrTimeStart);
this.initializeConversationMemory(config, constructorId, constructorStartTime, constructorHrTimeStart);
this.initializeExternalServerManager(constructorId, constructorStartTime, constructorHrTimeStart);
this.initializeHITL(config, constructorId, constructorStartTime, constructorHrTimeStart);
this.initializeMCPEnhancements(config);
this.registerFileTools();
this.registerMemoryRetrievalTools();
if (config?.skills?.enabled) {
this.skillsConfig = config.skills;
this.registerSkillTools();
}
this.initializeLangfuse(constructorId, constructorStartTime, constructorHrTimeStart);
this.initializeMetricsListeners();
this.logConstructorComplete(constructorId, constructorStartTime, constructorHrTimeStart);
// Store auth config for lazy initialization
if (config?.auth) {
this.pendingAuthConfig = config.auth;
}
// Store per-provider credential overrides
if (config?.credentials) {
this.credentials = config.credentials;
}
// Store task config for lazy initialization
this._taskManagerConfig = config?.tasks;
// Eagerly create TaskManager and register tools if config is provided
if (this._taskManagerConfig) {
this._taskManager = new TaskManager(this, this._taskManagerConfig);
this._taskManager.setEmitter(this.emitter);
this.registerSchedulerTaskTools(this._taskManager);
}
}
/**
* TaskManager — scheduled and self-running tasks.
* Lazy-initialized on first access. Configurable via constructor `tasks` option.
* The actual async initialization (Redis connect, backend start) happens
* lazily inside TaskManager on first operation.
*/
get tasks() {
if (!this._taskManager) {
this._taskManager = new TaskManager(this, this._taskManagerConfig);
this._taskManager.setEmitter(this.emitter);
this.registerSchedulerTaskTools(this._taskManager);
}
return this._taskManager;
}
/**
* Initialize provider registry with security settings
*/
initializeProviderRegistry(constructorId, constructorStartTime, constructorHrTimeStart) {
const registrySetupStartTime = process.hrtime.bigint();
logger.debug(`[NeuroLink] 🏗️ LOG_POINT_C002_PROVIDER_REGISTRY_SETUP_START`, {
logPoint: "C002_PROVIDER_REGISTRY_SETUP_START",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
registrySetupStartTimeNs: registrySetupStartTime.toString(),
message: "Starting ProviderRegistry configuration for security",
});
ProviderRegistry.setOptions({ enableManualMCP: false });
}
/**
* Initialize conversation memory if enabled
*/
initializeConversationMemory(config, constructorId, constructorStartTime, constructorHrTimeStart) {
if (config?.conversationMemory?.enabled) {
const memoryInitStartTime = process.hrtime.bigint();
// Store config for later use and set flag for lazy initialization
this.conversationMemoryConfig = config;
this.conversationMemoryNeedsInit = true;
const memoryInitEndTime = process.hrtime.bigint();
const memoryInitDurationNs = memoryInitEndTime - memoryInitStartTime;
logger.debug(`[NeuroLink] ✅ LOG_POINT_C006_MEMORY_INIT_FLAG_SET_SUCCESS`, {
logPoint: "C006_MEMORY_INIT_FLAG_SET_SUCCESS",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
memoryInitDurationNs: memoryInitDurationNs.toString(),
memoryInitDurationMs: Number(memoryInitDurationNs) / NANOSECOND_TO_MS_DIVISOR,
message: "Conversation memory initialization flag set successfully for lazy loading",
});
}
else {
logger.debug(`[NeuroLink] 🚫 LOG_POINT_C008_MEMORY_DISABLED`, {
logPoint: "C008_MEMORY_DISABLED",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
hasConfig: !!config,
hasMemoryConfig: !!config?.conversationMemory,
memoryEnabled: config?.conversationMemory?.enabled || false,
reason: !config
? "NO_CONFIG"
: !config.conversationMemory
? "NO_MEMORY_CONFIG"
: !config.conversationMemory.enabled
? "MEMORY_DISABLED"
: "UNKNOWN",
message: "Conversation memory not enabled - skipping initialization",
});
}
}
/**
* Initialize HITL (Human-in-the-Loop) if enabled
*/
initializeHITL(config, constructorId, constructorStartTime, constructorHrTimeStart) {
if (config?.hitl?.enabled) {
const hitlInitStartTime = process.hrtime.bigint();
logger.debug(`[NeuroLink] 🛡️ LOG_POINT_C015_HITL_INIT_START`, {
logPoint: "C015_HITL_INIT_START",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
hitlInitStartTimeNs: hitlInitStartTime.toString(),
hitlConfig: {
enabled: config.hitl.enabled,
dangerousActions: config.hitl.dangerousActions || [],
timeout: config.hitl.timeout || 30000,
allowArgumentModification: config.hitl.allowArgumentModification ?? true,
auditLogging: config.hitl.auditLogging ?? false,
},
message: "Starting HITL (Human-in-the-Loop) initialization",
});
try {
// Initialize HITL manager
this.hitlManager = new HITLManager(config.hitl);
// Inject HITL manager into tool registry
this.toolRegistry.setHITLManager(this.hitlManager);
// Inject HITL manager into external server manager
this.externalServerManager.setHITLManager(this.hitlManager);
// Set up HITL event forwarding to main emitter
this.setupHITLEventForwarding();
const hitlInitEndTime = process.hrtime.bigint();
const hitlInitDurationNs = hitlInitEndTime - hitlInitStartTime;
logger.debug(`[NeuroLink] ✅ LOG_POINT_C016_HITL_INIT_SUCCESS`, {
logPoint: "C016_HITL_INIT_SUCCESS",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
hitlInitDurationNs: hitlInitDurationNs.toString(),
hitlInitDurationMs: Number(hitlInitDurationNs) / NANOSECOND_TO_MS_DIVISOR,
hasHitlManager: !!this.hitlManager,
message: "HITL (Human-in-the-Loop) initialized successfully",
});
logger.info(`[NeuroLink] HITL safety features enabled`, {
dangerousActions: config.hitl.dangerousActions?.length || 0,
timeout: config.hitl.timeout || 30000,
allowArgumentModification: config.hitl.allowArgumentModification ?? true,
auditLogging: config.hitl.auditLogging ?? false,
});
}
catch (error) {
const hitlInitErrorTime = process.hrtime.bigint();
const hitlInitDurationNs = hitlInitErrorTime - hitlInitStartTime;
logger.error(`[NeuroLink] ❌ LOG_POINT_C017_HITL_INIT_ERROR`, {
logPoint: "C017_HITL_INIT_ERROR",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
hitlInitDurationNs: hitlInitDurationNs.toString(),
hitlInitDurationMs: Number(hitlInitDurationNs) / NANOSECOND_TO_MS_DIVISOR,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : "UnknownError",
errorStack: error instanceof Error ? error.stack : undefined,
message: "HITL (Human-in-the-Loop) initialization failed",
});
throw error;
}
}
else {
logger.debug(`[NeuroLink] 🚫 LOG_POINT_C018_HITL_DISABLED`, {
logPoint: "C018_HITL_DISABLED",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
hasConfig: !!config,
hasHitlConfig: !!config?.hitl,
hitlEnabled: config?.hitl?.enabled || false,
reason: !config
? "NO_CONFIG"
: !config.hitl
? "NO_HITL_CONFIG"
: !config.hitl.enabled
? "HITL_DISABLED"
: "UNKNOWN",
message: "HITL (Human-in-the-Loop) not enabled - skipping initialization",
});
}
}
/**
* Initialize MCP enhancement modules (cache, router, batcher, discovery).
* Wires standalone MCP modules into the core SDK execution path.
*/
initializeMCPEnhancements(config) {
const mcpConfig = config?.mcp;
this.mcpEnhancementsConfig = mcpConfig;
// BZ-664: ToolCache — enabled by default to prevent duplicate tool calls.
// Callers can explicitly opt out via mcp.cache.enabled = false.
if (mcpConfig?.cache?.enabled !== false) {
this.mcpToolResultCache = new ToolResultCache({
ttl: mcpConfig?.cache?.ttl ?? 300_000,
maxSize: mcpConfig?.cache?.maxSize ?? 500,
strategy: mcpConfig?.cache?.strategy ?? "lru",
});
logger.debug("[NeuroLink] MCP tool result cache initialized", {
ttl: mcpConfig?.cache?.ttl ?? 300_000,
maxSize: mcpConfig?.cache?.maxSize ?? 500,
strategy: mcpConfig?.cache?.strategy ?? "lru",
});
}
// ToolCallBatcher — disabled by default, opt-in
if (mcpConfig?.batcher?.enabled) {
this.mcpToolBatcher = new ToolCallBatcher({
maxBatchSize: mcpConfig.batcher.maxBatchSize ?? 10,
maxWaitMs: mcpConfig.batcher.maxWaitMs ?? 100,
});
// Wire batcher to execute tools via the internal execution path (bypass batcher itself)
this.mcpToolBatcher.setToolExecutor(async (tool, args) => {
return this.executeToolInternal(tool, args, {
timeout: TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS,
maxRetries: RETRY_ATTEMPTS.DEFAULT,
retryDelayMs: RETRY_DELAYS.BASE_MS,
});
});
logger.debug("[NeuroLink] MCP tool call batcher initialized");
}
// EnhancedToolDiscovery — enabled by default
if (mcpConfig?.discovery?.enabled !== false) {
this.mcpEnhancedDiscovery = new EnhancedToolDiscovery();
logger.debug("[NeuroLink] Enhanced tool discovery initialized");
}
// Middleware — store from config (empty by default)
if (mcpConfig?.middleware?.length) {
this.mcpToolMiddlewares = [...mcpConfig.middleware];
logger.debug("[NeuroLink] MCP tool middlewares registered", {
count: this.mcpToolMiddlewares.length,
});
}
// ToolRouter — lazy-initialized when 2+ external servers exist (see addExternalMCPServer)
// Artifact storage — where externalized MCP outputs and banked payloads
// live. Kept so both creation sites (externalize here, and
// getArtifactStore() on demand) resolve the same backend.
this.artifactsConfig = config?.artifacts;
// McpOutputNormalizer — active when mcp.outputLimits is configured
if (mcpConfig?.outputLimits) {
const strategy = mcpConfig.outputLimits.strategy ?? "externalize";
const maxBytes = mcpConfig.outputLimits.maxBytes ?? DEFAULT_MAX_MCP_OUTPUT_BYTES;
const warnBytes = mcpConfig.outputLimits.warnBytes ?? DEFAULT_WARN_MCP_OUTPUT_BYTES;
this.mcpOutputNormalizerConfig = { strategy, maxBytes, warnBytes };
let artifactStore;
if (strategy === "externalize") {
artifactStore = this.createConfiguredArtifactStore();
this.mcpArtifactStore = artifactStore;
}
this.installOutputNormalizer(artifactStore);
}
}
/**
* Register file reference tools with the MCP tool registry.
*
* Creates file access tools (list_attached_files, read_file_section,
* search_in_file, get_file_preview) bound to the FileReferenceRegistry
* and registers them as direct tools so they're available to LLMs.
*/
registerFileTools() {
const fileTools = createFileTools(this.fileRegistry);
// Use void to handle async registration without blocking constructor
const registrations = Object.entries(fileTools).map(async ([toolName, toolDef]) => {
const toolId = `direct.${toolName}`;
// Register the real parameter schema (converted from Zod) instead of a
// `{}` placeholder so tool listings and token-budget accounting see the
// schema that actually ships to providers.
const fileToolSchema = convertZodToJsonSchema((toolDef.inputSchema ??
toolDef.parameters));
const toolInfo = {
name: toolName,
description: toolDef.description || `File tool: ${toolName}`,
inputSchema: fileToolSchema,
serverId: "direct",
category: "built-in",
};
await this.toolRegistry.registerTool(toolId, toolInfo, {
execute: async (params) => {
try {
const result = await toolDef.execute(params, {
toolCallId: "file-tool",
messages: [],
});
return {
success: true,
data: result,
metadata: { toolName, serverId: "direct", executionTime: 0 },
};
}
catch (error) {
// Known limitation: this non-throwing error path returns
// { success: false } without recording errorCategories in
// toolExecutionMetrics. These are internal file-tool failures
// (low frequency), so the risk of metric gaps is minimal.
// A full fix would require access to the metrics map here,
// which is not available in the registration closure.
return {
success: false,
error: error instanceof Error ? error.message : String(error),
metadata: { toolName, serverId: "direct", executionTime: 0 },
};
}
},
description: toolDef.description,
inputSchema: {},
});
});
// Fire-and-forget: registrations complete before any generate/stream call
// because those calls await initializeMCP() which is slower.
//
// The rejection handler is not decoration. registerTool() throws on a
// failed name/description validation (mcp/toolRegistry.ts), and that throw
// is not inside a try — so a rejected registration on a `void`-detached
// one-argument `.then()` would be an unhandled rejection, which terminates
// the process. Today the tool names come from createFileTools(), which are
// internal constants that pass validation, so this is latent rather than
// live; it stops being latent the moment a name is derived from anything
// outside this file. Losing one tool registration is the intended failure
// mode here — losing the host process is not.
void Promise.all(registrations).then(() => {
logger.debug(`[NeuroLink] Registered ${Object.keys(fileTools).length} file reference tools`);
}, (error) => {
logger.warn("[NeuroLink] File tool registration failed", {
error: error instanceof Error ? error.message : String(error),
});
});
}
/**
* Register SCHEDULER task tools bound to a TaskManager instance (task_create,
* task_list, … — scheduled/self-running jobs). Distinct from the agent task
* CHECKLIST (registerTaskTools() / tasks_create): different tools, different
* state, different purpose.
* Follows the same factory + registry pattern as registerFileTools().
* Called when TaskManager is created (eagerly or lazily via the `tasks` getter).
*/
registerSchedulerTaskTools(manager) {
const taskTools = createTaskTools(manager);
for (const [toolName, toolDef] of Object.entries(taskTools)) {
const toolId = `direct.${toolName}`;
const toolInfo = {
name: toolName,
description: toolDef.description || `Task tool: ${toolName}`,
inputSchema: {},
serverId: "direct",
category: "built-in",
};
// registerTool is async but its core logic is synchronous (Map.set).
// We fire-and-forget here but tools are available immediately after
// the synchronous validation + map insertion completes.
//
// The .catch() at the end of this call is required for the same reason
// as in registerFileTools() above: registerTool() throws on failed
// validation, and an unhandled rejection off a `void`-detached call
// terminates the process rather than failing this one registration.
// Latent today (createTaskTools() supplies internal, valid names), and
// cheap to keep correct.
void this.toolRegistry
.registerTool(toolId, toolInfo, {
execute: async (params) => {
try {
const result = await toolDef.execute(params, {
toolCallId: "task-tool",
messages: [],
});
return {
success: true,
data: result,
metadata: { toolName, serverId: "direct", executionTime: 0 },
};
}
catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
metadata: { toolName, serverId: "direct", executionTime: 0 },
};
}
},
description: toolDef.description,
inputSchema: {},
})
.catch((error) => {
logger.warn("[NeuroLink] Task tool registration failed", {
toolId,
error: error instanceof Error ? error.message : String(error),
});
});
}
logger.debug(`[NeuroLink] Registered ${Object.keys(taskTools).length} task tools`);
}
/**
* Register memory retrieval tools that allow the AI to access
* conversation history, including full tool outputs.
* Only registered when Redis conversation memory is active.
*/
registerMemoryRetrievalTools() {
if (this.retrieveContextRegistered) {
return;
}
// Check if conversation memory is configured
// Memory retrieval tool requires Redis (getSessionRaw) but registration
// is deferred — the execute handler checks at runtime whether the actual
// memory manager supports getSessionRaw.
const memConfig = this.conversationMemoryConfig?.conversationMemory;
const hasRedisConfig = !!memConfig?.redisConfig ||
(memConfig &&
"redis" in memConfig &&
!!memConfig.redis) ||
process.env.STORAGE_TYPE === "redis";
const hasArtifactStore = !!this.mcpArtifactStore;
// Register when Redis is configured OR when an artifact store exists.
// Artifact store alone is sufficient for the artifactId retrieval path —
// session history retrieval just returns a clear error when Redis is absent.
if ((!memConfig?.enabled || !hasRedisConfig) && !hasArtifactStore) {
logger.debug("[NeuroLink] Skipping memory retrieval tools — requires Redis conversation memory or an artifact store");
return;
}
// Extract the canonical tool definition (schema + description) from the
// memoryRetrievalTools factory. We pass undefined as the memoryManager here
// because we only need the Zod inputSchema and description at registration
// time — the actual manager is resolved lazily at execution time.
const canonicalTools = createMemoryRetrievalTools(undefined, this.mcpArtifactStore);
const retrieveContextDef = canonicalTools.retrieve_context;
// Register via this.registerTool() so the tool ends up in the "user-defined"
// category inside toolRegistry. getCustomTools() returns that category, which
// is what ToolsManager reads to build the tool schema sent to the LLM.
// (Tools registered via toolRegistry.registerTool() directly land in the
// "built-in" category and are never included in the LLM's tool schema.)
this.registerTool("retrieve_context", {
name: "retrieve_context",
description: retrieveContextDef.description ?? "Retrieve context or artifacts",
// Pass the Zod schema so ToolsManager gives the LLM full parameter types.
// registerTool() detects isZodSchema on inputSchema and preserves it.
inputSchema: retrieveContextDef.inputSchema,
execute: async (params) => {
// Lazy: conversationMemory is initialized on the first generate() call.
// It may be undefined (artifact-store-only), the Redis manager, or the
// in-memory manager (no Redis config, or Redis init fell back) —
// createMemoryRetrievalTools guards session retrieval on getSessionRaw
// capability and returns a descriptive error otherwise.
const tools = createMemoryRetrievalTools(this.conversationMemory ?? undefined, this.mcpArtifactStore);
// Return the result directly so the LLM receives clean output instead
// of a nested { success, data, metadata } wrapper.
// Bounded by TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS so a stalled Redis or
// filesystem backend never hangs the tool call indefinitely.
return await withTimeout(tools.retrieve_context.execute(params, { toolCallId: "memory-retrieval", messages: [] }), TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS, ErrorFactory.toolTimeout("retrieve_context", TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS));
},
});
this.retrieveContextRegistered = true;
logger.info("[NeuroLink] Memory retrieval tools registered");
}
/**
* Lazy initialization for the skills subsystem — mirrors ensureMemoryReady().
* Returns null (and stays null) when skills are not configured or the
* store failed to initialize; read paths fail open on that null.
*/
ensureSkillsReady() {
if (this.skillsManagerInstance !== undefined) {
return this.skillsManagerInstance;
}
if (!this.skillsConfig?.enabled) {
this.skillsManagerInstance = null;
return null;
}
try {
this.skillsManagerInstance = new SkillsManager(this.skillsConfig);
}
catch (error) {
logger.warn("[NeuroLink] Skills initialization failed — skills disabled for this instance", { error: error instanceof Error ? error.message : String(error) });
this.skillsManagerInstance = null;
}
return this.skillsManagerInstance;
}
/**
* Register the built-in skill tools (list_skills, plus
* mutation tools when allowMutations is set). Follows the
* registerMemoryRetrievalTools() pattern: registered via registerTool()
* so they land in the "user-defined" category that reaches the LLM tool
* schema, with the manager resolved lazily at execution time.
*/
registerSkillTools() {
const canonicalTools = createSkillTools(() => this.ensureSkillsReady(), {
allowMutations: this.skillsConfig?.allowMutations === true,
});
for (const [toolName, toolDef] of Object.entries(canonicalTools)) {
this.registerTool(toolName, {
name: toolName,
description: toolDef.description ?? toolName,
// Zod schema — registerTool() detects isZodSchema and preserves it
// so ToolsManager gives the LLM full parameter types.
inputSchema: toolDef.inputSchema,
execute: async (params) => withTimeout(toolDef.execute(params, { toolCallId: "skill-tool", messages: [] }), TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS, ErrorFactory.toolTimeout(toolName, TOOL_TIMEOUTS.EXECUTION_DEFAULT_MS)),
});
}
logger.info(`[NeuroLink] Registered ${Object.keys(canonicalTools).length} skill tools`, { allowMutations: this.skillsConfig?.allowMutations === true });
}
/**
* Skills augmentation for one generate()/stream() call:
* 1. Discovery — surface the skills listing per the resolved mode:
* embedded in the use_skill tool description ("tool", default) or
* appended to the system prompt ("system-prompt").
* 2. Per-call tools — inject use_skill + read_skill_resource into
* options.tools (the RAG-tool pattern; same-name entries shadow
* registered tools) with the sessionId closure-bound so activations
* pin to the session.
* 3. Preload — activate host-requested skills up front, injecting their
* instructions into this call's system prompt and pinning them.
* Fails open: any error leaves the call untouched.
*/
async applySkillsAugmentation(options) {
if (!this.skillsConfig?.enabled || options.skills?.enabled === false) {
return;
}
// Media-only modes have no meaningful text prompt to augment.
const mode = options.output?.mode;
if (mode === "avatar" ||
mode === "music" ||
mode === "video" ||
mode === "ppt") {
return;
}
try {
const manager = this.ensureSkillsReady();
if (!manager) {
return;
}
const discovery = options.skills?.discovery ?? this.skillsConfig.discovery ?? "tool";
const scopeId = options.skills?.scopeId ?? this.skillsConfig.defaultScopeId;
const tags = options.skills?.tags;
const sessionId = this.resolveSkillSessionId(options.context) ??
this.resolveSkillSessionId(options);
const userId = this.resolveSkillUserId(options.context) ??
this.resolveSkillUserId(options);
// Pinning requires somewhere to pin: without conversation memory the
// drained messages would be silently discarded while dedup reports
// already_loaded — so persistence is only on when memory is
// configured (or already initialized).
const memoryAvailable = Boolean(this.conversationMemory) ||
Boolean(this.conversationMemoryConfig?.conversationMemory?.enabled);
const sessionPersistence = (this.skillsConfig.sessionPersistence ?? true) &&
Boolean(sessionId) &&
memoryAvailable;
const visibility = {
...(scopeId !== undefined ? { scopeId } : {}),
...(tags !== undefined ? { tags } : {}),
};
if (discovery === "system-prompt") {
const block = await manager.buildPromptIndex(visibility);
if (block) {
options.systemPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${block}`
: block;
}
}
const listing = discovery === "tool"
? await manager.buildToolListing(visibility)
: null;
const callTools = createSkillCallTools(() => this.ensureSkillsReady(), {
...(sessionId ? { sessionId } : {}),
...(scopeId !== undefined ? { scopeId } : {}),
sessionPersistence,
discovery,
listing,
// userId rides by closure: Redis memory keys sessions by
// userId:sessionId, so hydration must read the same key the
// store-turn path writes.
getStoredMessages: async (sid) => this.conversationMemory
? await this.conversationMemory.getSessionMessages(sid, userId)
: [],
});
// Caller-supplied per-call tools win over the injected ones (a host
// may bring its own use_skill); the injected tools still shadow any
// registered base tool of the same name via the provider merge.
options.tools = {
...callTools,
...(options.tools ?? {}),
};
if (options.skills?.preload?.length) {
await this.preloadSkills(manager, options, options.skills.preload, {
...(sessionId ? { sessionId } : {}),
...(userId ? { userId } : {}),
sessionPersistence,
...(scopeId !== undefined ? { scopeId } : {}),
});
}
logger.debug("[NeuroLink] Skills augmentation applied", {
discovery,
listingLength: listing?.length ?? 0,
sessionPersistence,
preloadCount: options.skills?.preload?.length ?? 0,
});
}
catch (error) {
logger.warn("[NeuroLink] Skills augmentation failed — continuing without skills", { error: error instanceof Error ? error.message : String(error) });
}
}
/** Session id for skill activation tracking, from the call context. */
resolveSkillSessionId(context) {
const fromContext = context
?.sessionId;
return typeof fromContext === "string" && fromContext
? fromContext
: undefined;
}
/** User id for skill-session hydration (Redis keys sessions by userId). */
resolveSkillUserId(context) {
const fromContext = context?.userId;
return typeof fromContext === "string" && fromContext
? fromContext
: undefined;
}
/**
* Activate host-requested skills before the model runs: instructions go
* into this call's system prompt; when persisting, the activation is
* pinned so later turns replay it from history instead. Unknown or
* already-active names are skipped with a warn/debug log (fail-open).
*/
async preloadSkills(manager, options, names, call) {
const { sessionId, userId, sessionPersistence, scopeId } = call;
for (const name of names) {
const skill = await manager.get(name);
if (!skill || !isSkillVisibleInScope(skill, scopeId)) {
logger.warn("[NeuroLink] Preload skill not found — skipping", {
skill: name,
});
continue;
}
let block;
if (sessionId && sessionPersistence) {
// Always hydrate (empty history when memory isn't initialized yet):
// the tracker derives truth from stored pins + this turn's pending,
// never from stale in-process records.
manager.sessions.hydrate(sessionId, this.conversationMemory
? await this.conversationMemory.getSessionMessages(sessionId, userId)
: []);
if (manager.sessions.isActive(sessionId, skill.id, skill.name)) {
continue;
}
block = manager.sessions.recordActivation(sessionId, skill).content;
}
else {
block = buildSkillActivationMessage(skill).content;
}
options.systemPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${block}`
: block;
}
}
/**
* Pinned skill messages recorded during this turn, ready for
* StoreConversationTurnOptions.skillMessages. Empty when skills are off
* or nothing was activated.
*/
drainPendingSkillMessages(sessionId) {
if (typeof sessionId !== "string" || !sessionId) {
return [];
}
const manager = this.skillsManagerInstance;
if (!manager) {
return [];
}
return manager.sessions.drainPending(sessionId);
}
/**
* Programmatic access to the skills subsystem (search/list/get/mutations).
* Returns null when skills are not configured or failed to initialize.
*/
getSkillsManager() {
return this.ensureSkillsReady();
}
/** Format memory context for prompt inclusion */
formatMemoryContext(memoryContext, currentInput) {
return `Context from previous conversations:
${memoryContext}
Current user's request: ${currentInput}`;
}
/**
* Format memory context from multiple users into a labeled block.
*/
formatMultiUserMemoryContext(memories, currentInput) {
const memoryBlocks = [];
for (const [label, memory] of memories) {
memoryBlocks.push(`[${label}]\n${memory}`);
}
return `Context from previous conversations:
${memoryBlocks.join("\n\n")}
Current user's request: ${currentInput}`;
}
/**
* Determine whether memory should be read (retrieved) for this call.
* Respects both the global memory SDK config and per-call overrides.
*/
shouldReadMemory(perCallMemory, userId) {
if (!this.conversationMemoryConfig?.conversationMemory?.memory?.enabled ||
!userId) {
return false;
}
if (perCallMemory?.enabled === false) {
return false;
}
if (perCallMemory?.read === false) {
return false;
}
return true;
}
/**
* Determine whether memory should be written (stored) for this call.
* Respects both the global memory SDK config and per-call overrides.
*/
shouldWriteMemory(perCallMemory, userId, content) {
if (!this.conversationMemoryConfig?.conversationMemory?.memory?.enabled ||
!userId) {
return false;
}
if (!content?.trim()) {
return false;
}
if (perCallMemory?.enabled === false) {
return false;
}
if (perCallMemory?.write === false) {
return false;
}
return true;
}
/**
* Retrieve condensed memory for a user (and optionally additional users).
* Returns the input text enhanced with memory context, or unchanged if no memory.
*/
async retrieveMemory(inputText, userId, additionalUsers) {
const client = this.ensureMemoryReady();
if (!client) {
return inputText;
}
// Collect all user IDs to read (primary + additional users with read !== false)
const readableAdditional = (additionalUsers || []).filter((u) => u.read !== false);
if (readableAdditional.length === 0) {
// Single user — use original fast path
const memory = await client.get(userId);
if (!memory) {
return inputText;
}
return this.formatMemoryContext(memory, inputText);
}
// Multi-user: fetch all memories in parallel
// Build entries with labels for formatting
const entries = [
{ id: userId, label: "User" },
...readableAdditional.map((u) => ({
id: u.userId,
label: u.label || u.userId,
})),
];
const results = await Promise.all(entries.map(async (entry) => {
const memory = await client.get(entry.id);
return { ...entry, memory };
}));
const memories = new Map();
for (const { label, memory } of results) {
if (memory) {
memories.set(label, memory);
}
}
if (memories.size === 0) {
return inputText;
}
return this.formatMultiUserMemoryContext(memories, inputText);
}
/**
* Store a conversation turn in memory (non-blocking).
* Calls add(userId, content) which internally condenses old + new via LLM.
* Supports additional users with per-user prompt and maxWords overrides.
*/
storeMemoryInBackground(originalPrompt, responseContent, userId, additionalUsers, langfuseIdentity) {
const memoryWrite = async () => {
try {
const client = this.ensureMemoryReady();
if (!client) {
return;
}
const content = `User: ${originalPrompt}\nAssistant: ${responseContent}`;
// Collect all users to write: primary + additional users with write !== false
const writeOps = [client.add(userId, content)];
const writableAdditional = (additionalUsers || []).filter((u) => u.write !== false);
for (const user of writableAdditional) {
const addOptions = user.prompt || user.maxWords
? { prompt: user.prompt, maxWords: user.maxWords }
: undefined;
writeOps.push(client.add(user.userId, content, addOptions));
}
// withTimeout races against Promise.all — if the timeout fires, the
// await resolves with an error but the underlying client.add() calls
// may still complete in the background. This is acceptable: the memory
// client API (Mem0) doesn't support AbortSignal, and these are
// fire-and-forget background writes where a stale completion is harmless.
await withTimeout(Promise.all(writeOps), 30_000, new Error("Background memory write timed out after 30s"));
}
catch (error) {
logger.warn("Memory storage failed:", error);
}
};
// Carry the turn's identity across the setImmediate boundary so the
// condensation generate + redis spans don't orphan to "guest". Keep the
// ambient store when it survived (generate path — carries conversationId,
// metadata, …); re-establish from the caller only when it was lost (stream
// path, which fires after the caller consumed the stream).
const ambient = getLangfuseContext();
const wrappedMemoryWrite = !(ambient?.traceName || ambient?.userId) &&
(langfuseIdentity?.traceName || langfuseIdentity?.sessionId)
? () => setLangfuseContext({
userId,
sessionId: langfuseIdentity.sessionId ?? null,
traceName: langfuseIdentity.traceName ?? null,
}, memoryWrite)
: runWithCurrentLangfuseContext(memoryWrite);
setImmediate(wrappedMemoryWrite);
}
/**
* Set up HITL event forwarding to main emitter
*/
setupHITLEventForwarding() {
if (!this.hitlManager) {
return;
}
// Forward HITL confirmation requests to main emitter
this.hitlManager.on("hitl:confirmation-request", (event) => {
logger.debug("Forwarding HITL confirmation request", {
confirmationId: event.payload?.confirmationId,
toolName: event.payload?.toolName,
});
this.emitter.emit("hitl:confirmation-request", event);
});
// Forward HITL timeout events to main emitter
this.hitlManager.on("hitl:timeout", (event) => {
logger.debug("Forwarding HITL timeout event", {
confirmationId: event.payload?.confirmationId,
toolName: event.payload?.toolName,
});
this.emitter.emit("hitl:timeout", event);
});
// Listen for confirmation responses from main emitter and forward to HITL manager
this.emitter.on("hitl:confirmation-response", (event) => {
const typedEvent = event;
logger.debug("Received HITL confirmation response", {
confirmationId: typedEvent.payload?.confirmationId,
approved: typedEvent.payload?.approved,
});
// Forward to HITL manager
this.hitlManager?.emit("hitl:confirmation-response", typedEvent);
});
logger.debug("HITL event forwarding configured successfully");
}
/**
* Initialize external server manager with event handlers
*/
initializeExternalServerManager(constructorId, constructorStartTime, constructorHrTimeStart) {
const externalServerInitStartTime = process.hrtime.bigint();
try {
this.externalServerManager = new ExternalServerManager({
maxServers: 20,
defaultTimeout: Math.max(10000, Number(process.env.MCP_CLIENT_TIMEOUT) || 60000),
enableAutoRestart: true,
enablePerformanceMonitoring: true,
}, {
enableMainRegistryIntegration: true,
});
const externalServerInitEndTime = process.hrtime.bigint();
const externalServerInitDurationNs = externalServerInitEndTime - externalServerInitStartTime;
logger.debug(`[NeuroLink] ✅ LOG_POINT_C010_EXTERNAL_SERVER_INIT_SUCCESS`, {
logPoint: "C010_EXTERNAL_SERVER_INIT_SUCCESS",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
externalServerInitDurationNs: externalServerInitDurationNs.toString(),
externalServerInitDurationMs: Number(externalServerInitDurationNs) / NANOSECOND_TO_MS_DIVISOR,
hasExternalServerManager: !!this.externalServerManager,
message: "External server manager initialized successfully",
});
this.setupExternalServerEventHandlers(constructorId);
}
catch (error) {
const externalServerInitErrorTime = process.hrtime.bigint();
const externalServerInitDurationNs = externalServerInitErrorTime - externalServerInitStartTime;
logger.error(`[NeuroLink] ❌ LOG_POINT_C013_EXTERNAL_SERVER_INIT_ERROR`, {
logPoint: "C013_EXTERNAL_SERVER_INIT_ERROR",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
externalServerInitDurationNs: externalServerInitDurationNs.toString(),
externalServerInitDurationMs: Number(externalServerInitDurationNs) / NANOSECOND_TO_MS_DIVISOR,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : "UnknownError",
errorStack: error instanceof Error ? error.stack : undefined,
message: "External server manager initialization failed",
});
throw error;
}
}
/**
* Setup event handlers for external server manager
*/
setupExternalServerEventHandlers(constructorId) {
this.externalServerManager.on("connected", (event) => {
logger.debug(`[NeuroLink] 🔗 EXTERNAL_SERVER_EVENT_CONNECTED`, {
constructorId,
eventType: "connected",
event,
timestamp: new Date().toISOString(),
message: "External MCP server connected event received",
});
this.emitter.emit("externalMCP:serverConnected", event);
});
this.externalServerManager.on("disconnected", (event) => {
logger.debug(`[NeuroLink] 🔗 EXTERNAL_SERVER_EVENT_DISCONNECTED`, {
constructorId,
eventType: "disconnected",
event,
timestamp: new Date().toISOString(),
message: "External MCP server disconnected event received",
});
this.emitter.emit("externalMCP:serverDisconnected", event);
});
this.externalServerManager.on("failed", (event) => {
logger.warn(`[NeuroLink] 🔗 EXTERNAL_SERVER_EVENT_FAILED`, {
constructorId,
eventType: "failed",
event,
timestamp: new Date().toISOString(),
message: "External MCP server failed event received",
});
this.emitter.emit("externalMCP:serverFailed", event);
});
this.externalServerManager.on("toolDiscovered", (event) => {
logger.debug(`[NeuroLink] 🔗 EXTERNAL_SERVER_EVENT_TOOL_DISCOVERED`, {
constructorId,
eventType: "toolDiscovered",
toolName: event.toolName,
serverId: event.serverId,
timestamp: new Date().toISOString(),
message: "External MCP tool discovered event received",
});
this.emitter.emit("externalMCP:toolDiscovered", event);
});
this.externalServerManager.on("toolRemoved", (event) => {
logger.debug(`[NeuroLink] 🔗 EXTERNAL_SERVER_EVENT_TOOL_REMOVED`, {
constructorId,
eventType: "toolRemoved",
toolName: event.toolName,
serverId: event.serverId,
timestamp: new Date().toISOString(),
message: "External MCP tool removed event received",
});
this.emitter.emit("externalMCP:toolRemoved", event);
this.unregisterExternalMCPToolFromRegistry(event.toolName);
});
}
/**
* Initialize Langfuse observability for AI operations tracking
*/
initializeLangfuse(constructorId, constructorStartTime, constructorHrTimeStart) {
const langfuseInitStartTime = process.hrtime.bigint();
try {
const langfuseConfig = this.observabilityConfig?.langfuse;
// Check if we should use external provider mode - bypass enabled check
const useExternalProvider = langfuseConfig?.autoDetectExternalProvider === true ||
langfuseConfig?.useExternalTracerProvider === true;
if (langfuseConfig?.enabled || useExternalProvider) {
logger.debug(`[NeuroLink] 📊 LOG_POINT_C019_LANGFUSE_INIT_START`, {
logPoint: "C019_LANGFUSE_INIT_START",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
langfuseInitStartTimeNs: langfuseInitStartTime.toString(),
message: "Starting Langfuse observability initialization",
});
// Initialize OpenTelemetry (sets defaults from config)
void initializeOpenTelemetry(langfuseConfig).catch((err) => {
logger.error("[NeuroLink] OpenTelemetry initialization failed", {
error: err instanceof Error ? err.message : String(err),
});
});
const healthStatus = getLangfuseHealthStatus();
const langfuseInitDurationNs = process.hrtime.bigint() - langfuseInitStartTime;
if (healthStatus.initialized &&
healthStatus.hasProcessor &&
healthStatus.isHealthy) {
logger.debug(`[NeuroLink] ✅ LOG_POINT_C020_LANGFUSE_INIT_SUCCESS`, {
logPoint: "C020_LANGFUSE_INIT_SUCCESS",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
langfuseInitDurationNs: langfuseInitDurationNs.toString(),
langfuseInitDurationMs: Number(langfuseInitDurationNs) / 1_000_000,
healthStatus,
message: "Langfuse observability initialized successfully",
});
}
else {
logger.warn(`[NeuroLink] ⚠️ LOG_POINT_C021_LANGFUSE_INIT_WARNING`, {
logPoint: "C021_LANGFUSE_INIT_WARNING",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
langfuseInitDurationNs: langfuseInitDurationNs.toString(),
healthStatus,
message: "Langfuse initialized but not healthy",
});
}
}
else {
logger.debug(`[NeuroLink] 🚫 LOG_POINT_C022_LANGFUSE_DISABLED`, {
logPoint: "C022_LANGFUSE_DISABLED",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
message: "Langfuse observability not enabled - skipping initialization",
});
}
}
catch (error) {
const langfuseInitErrorDurationNs = process.hrtime.bigint() - langfuseInitStartTime;
logger.error(`[NeuroLink] ❌ LOG_POINT_C023_LANGFUSE_INIT_ERROR`, {
logPoint: "C023_LANGFUSE_INIT_ERROR",
constructorId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - constructorStartTime,
elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
langfuseInitDurationNs: langfuseInitErrorDurationNs.toString(),
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
message: "Langfuse observability initialization failed",
});
}
}
/**
* Log constructor completion with final state summary
*/
logConstructorComplete(constructorId, constructorStartTime, constructorHrTimeStart) {
const constructorEndTime = process.hrtime.bigint();
const constructorDurationNs = constructorEndTime - constructorHrTimeStart;
logger.debug(`🏁 LOG_POINT_C014_CONSTRUCTOR_COMPLETE`, {
logPoint: "C014_CONSTRUCTOR_COMPLETE",
constructorId,
timestamp: new Date().toISOString(),
constructorDurationNs: constructorDurationNs.toString(),
constructorDurationMs: Number(constructorDurationNs) / NANOSECOND_TO_MS_DIVISOR,
totalElapsedMs: Date.now() - constructorStartTime,
finalState: {
hasConversationMemory: !!this.conversationMemory,
hasExternalServerManager: !!this.externalServerManager,
hasEmitter: !!this.emitter,
mcpInitialized: this.mcpInitialized,
toolCircuitBreakersCount: this.toolCircuitBreakers.size,
toolExecutionMetricsCount: this.toolExecutionMetrics.size,
},
finalMemoryUsage: process.memoryUsage(),
finalCpuUsage: process.cpuUsage(),
message: "NeuroLink constructor completed successfully with all components initialized",
});
}
/**
* Initialize MCP registry with enhanced error handling and resource cleanup
* Uses isolated async context to prevent hanging
*/
async initializeMCP() {
// Skip if already initialized or explicitly skipped — prevents redundant
// re-init on every generate call.
if (this.mcpInitialized || this.mcpSkipped) {
return;
}
// Deduplicate concurrent initialization attempts — if an init is already
// in-flight, coalesce callers onto the same promise instead of running
// a second parallel initialization.
if (this.mcpInitPromise) {
return this.mcpInitPromise;
}
// Environment-level kill switch — skip MCP-specific initialization on cold
// start but always ensure providers are registered exactly once.
if (process.env.NEUROLINK_SKIP_MCP === "true") {
this.mcpInitPromise = (async () => {
await this.initializeProviderRegistryInternal();
this.mcpSkipped = true;
})();
try {
await this.mcpInitPromise;
}
finally {
this.mcpInitPromise = null;
}
return;
}
this.mcpInitPromise = this.performMCPInitializationOnce();
try {
await this.mcpInitPromise;
}
finally {
// Clear the in-flight promise so a future call (e.g. after cleanup/reset)
// can re-initialize if needed.
this.mcpInitPromise = null;
}
}
/**
* Actual one-shot MCP initialization logic. Called at most once per
* NeuroLink instance lifetime (unless cleanup() resets the flag).
*/
async performMCPInitializationOnce() {
const mcpInitId = `mcp-init-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const mcpInitStartTime = Date.now();
const mcpInitHrTimeStart = process.hrtime.bigint();
const MemoryManager = await this.importPerformanceManager(mcpInitId, mcpInitStartTime, mcpInitHrTimeStart);
const startMemory = MemoryManager
? MemoryManager.getMemoryUsageMB()
: { heapUsed: 0, heapTotal: 0, rss: 0, external: 0 };
try {
await this.performMCPInitialization(mcpInitId, mcpInitStartTime, mcpInitHrTimeStart, startMemory);
this.mcpInitialized = true;
this.logMCPInitComplete(startMemory, MemoryManager, mcpInitStartTime);
}
catch (error) {
const initializationTime = Date.now() - mcpInitStartTime;
const initializationTimeNs = process.hrtime.bigint() - mcpInitHrTimeStart;
mcpLogger.warn("[NeuroLink] MCP initialization failed", {
mcpInitId,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : "UnknownError",
errorStack: error instanceof Error ? error.stack : undefined,
initializationTime,
initializationTimeNs: initializationTimeNs.toString(),
initializationPhase: "performMCPInitialization",
memoryUsage: process.memoryUsage(),
timestamp: new Date().toISOString(),
gracefulDegradation: true,
});
// Continue without MCP - graceful degradation
}
}
/**
* Import performance manager with error handling
*/
async importPerformanceManager(mcpInitId, mcpInitStartTime, mcpInitHrTimeStart) {
const performanceImportStartTime = process.hrtime.bigint();
try {
const moduleImport = await import("./utils/performance.js");
const MemoryManager = moduleImport.MemoryManager;
return MemoryManager;
}
catch (error) {
const performanceImportErrorTime = process.hrtime.bigint();
const performanceImportDurationNs = performanceImportErrorTime - performanceImportStartTime;
logger.warn(`[NeuroLink] ⚠️ LOG_POINT_M005_PERFORMANCE_IMPORT_ERROR`, {
logPoint: "M005_PERFORMANCE_IMPORT_ERROR",
mcpInitId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - mcpInitStartTime,
elapsedNs: (process.hrtime.bigint() - mcpInitHrTimeStart).toString(),
performanceImportDurationNs: performanceImportDurationNs.toString(),
performanceImportDurationMs: Number(performanceImportDurationNs) / NANOSECOND_TO_MS_DIVISOR,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : "UnknownError",
message: "MemoryManager import failed - continuing without performance tracking",
});
return undefined;
}
}
/**
* Perform main MCP initialization logic
*/
async performMCPInitialization(mcpInitId, mcpInitStartTime, mcpInitHrTimeStart, startMemory) {
logger.info(`[NeuroLink] 🚀 LOG_POINT_M006_MCP_MAIN_INIT_START`, {
logPoint: "M006_MCP_MAIN_INIT_START",
mcpInitId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - mcpInitStartTime,
elapsedNs: (process.hrtime.bigint() - mcpInitHrTimeStart).toString(),
startMemory,
message: "Starting isolated MCP initialization process",
});
mcpLogger.debug("[NeuroLink] Starting isolated MCP initialization...");
await this.initializeToolRegistryInternal();
await this.initializeProviderRegistryInternal();
await this.registerDirectToolsServerInternal(mcpInitId, mcpInitStartTime, mcpInitHrTimeStart);
await this.loadMCPConfigurationInternal();
}
/**
* Initialize tool registry with timeout protection
*/
async initializeToolRegistryInternal() {
const initTimeout = 3000;
await Promise.race([
Promise.resolve(),
new Promise((_, reject) => {
setTimeout(() => reject(new Error("MCP initialization timeout")), initTimeout);
}),
]);
}
/**
* Initialize provider registry
*/
async initializeProviderRegistryInternal() {
await ProviderRegistry.registerAllProviders();
}
/**
* Register direct tools server
*/
async registerDirectToolsServerInternal(mcpInitId, mcpInitStartTime, mcpInitHrTimeStart) {
const directToolsStartTime = process.hrtime.bigint();
try {
if (process.env.NEUROLINK_DISABLE_DIRECT_TOOLS === "true") {
mcpLogger.debug("Direct tools server are disabled via environment variable.");
}
else {
await this.toolRegistry.registerServer("neurolink-direct", directToolsServer);
mcpLogger.debug("[NeuroLink] Direct tools server registered successfully", {
serverId: "neurolink-direct",
});
}
}
catch (error) {
const directToolsErrorTime = process.hrtime.bigint();
const directToolsDurationNs = directToolsErrorTime - directToolsStartTime;
logger.warn(`[NeuroLink] ⚠️ LOG_POINT_M013_DIRECT_TOOLS_ERROR`, {
logPoint: "M013_DIRECT_TOOLS_ERROR",
mcpInitId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - mcpInitStartTime,
elapsedNs: (process.hrtime.bigint() - mcpInitHrTimeStart).toString(),
directToolsDurationNs: directToolsDurationNs.toString(),
directToolsDurationMs: Number(directToolsDurationNs) / NANOSECOND_TO_MS_DIVISOR,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : "UnknownError",
errorStack: error instanceof Error ? error.stack : undefined,
serverId: "neurolink-direct",
message: "Direct tools server registration failed but continuing",
});
mcpLogger.warn("[NeuroLink] Failed to register direct tools server", {
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Load MCP configuration from .mcp-config.json with parallel loading for improved performance
*/
async loadMCPConfigurationInternal() {
try {
const configResult = await this.externalServerManager.loadMCPConfiguration(undefined, // Use default config path
{ parallel: true });
mcpLogger.debug("[NeuroLink] MCP configuration loaded successfully", {
serversLoaded: configResult.serversLoaded,
errors: configResult.errors.length,
});
if (configResult.errors.length > 0) {
mcpLogger.warn("[NeuroLink] Some MCP servers failed to load", {
errors: configResult.errors,
});
}
}
catch (configError) {
mcpLogger.warn("[NeuroLink] MCP configuration loading failed", {
error: configError instanceof Error
? configError.message
: String(configError),
});
}
}
/**
* Log MCP initialization completion
*/
logMCPInitComplete(startMemory, MemoryManager, mcpInitStartTime) {
const endMemory = MemoryManager
? MemoryManager.getMemoryUsageMB()
: { heapUsed: 0, heapTotal: 0, rss: 0, external: 0 };
const memoryDelta = endMemory.heapUsed - startMemory.heapUsed;
const initTime = Date.now() - mcpInitStartTime;
mcpLogger.debug("[NeuroLink] MCP initialization completed successfully", {
initTime: `${initTime}ms`,
memoryUsed: `${memoryDelta}MB`,
});
if (memoryDelta > MEMORY_THRESHOLDS.MODERATE_USAGE_MB) {
mcpLogger.debug("💡 Memory cleanup suggestion: MCP initialization used significant memory. Consider calling MemoryManager.forceGC() after heavy operations.");
}
}
/**
* Apply orchestration to determine optimal provider and model
* @param options - Original GenerateOptions
* @returns Modified options with orchestrated provider marked in context, or empty object if validation fails
*/
async applyOrchestration(options) {
const startTime = Date.now();
try {
// Ensure input.text exists before proceeding
if (!options.input?.text || typeof options.input.text !== "string") {
logger.debug("Orchestration skipped - no valid input text", {
hasInput: !!options.input,
hasText: !!options.input?.text,
textType: typeof options.input?.text,
});
return {}; // Return empty object to preserve existing fallback behavior
}
// Compute classification once to avoid duplicate calls
const classification = BinaryTaskClassifier.classify(options.input.text);
// Use the model router to get the optimal route
const route = ModelRouter.route(options.input.text);
// Validate that the routed provider is available and configured
const isProviderAvailable = await this.hasProviderEnvVars(route.provider);
if (!isProviderAvailable && route.provider !== "ollama") {
logger.debug("Orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: "Provider not configured or missing environment variables",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
// For Ollama, check if service is running and model is available
if (route.provider === "ollama") {
try {
const response = await fetch("http://localhost:11434/api/tags", {
method: "GET",
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
logger.debug("Orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: "Ollama service not responding",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
const responseData = await response.json();
const models = responseData?.models;
// Runtime-safe guard: ensure models is an array with valid objects
if (!Array.isArray(models)) {
logger.warn("Ollama API returned invalid models format", {
responseData,
modelsType: typeof models,
});
return {}; // Return empty object for fallback behavior
}
// Filter and validate models before comparison
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
const targetModel = route.model;
if (targetModel) {
// Check if the specific routed model is available
const modelIsAvailable = validModels.some((m) => m.name === targetModel);
if (!modelIsAvailable) {
logger.debug("Orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: `Ollama model '${targetModel}' not found`,
orchestrationTime: `${Date.now() - startTime}ms`,
});
// Fall back to first available model instead of abandoning orchestration
if (validModels.length > 0) {
route.model = validModels[0].name;
}
else {
return {}; // No models at all — preserve existing fallback behavior
}
}
}
else if (validModels.length === 0) {
// No model specified and none available
logger.debug("Orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: "No Ollama models available",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
else {
// No model specified but models are available — use the first one
route.model = validModels[0].name;
}
}
catch (error) {
logger.debug("Orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: error instanceof Error
? error.message
: "Ollama service check failed",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
}
logger.debug("Orchestration route determined", {
taskType: classification.type,
selectedProvider: route.provider,
selectedModel: route.model,
confidence: route.confidence,
reasoning: route.reasoning,
orchestrationTime: `${Date.now() - startTime}ms`,
});
// Mark preferred provider in context instead of directly setting provider
// This preserves global fallback behavior while indicating orchestration preference
return {
model: route.model,
context: {
...(options.context || {}),
__orchestratedPreferredProvider: route.provider,
},
};
}
catch (error) {
logger.error("Orchestration failed", {
error: error instanceof Error ? error.message : String(error),
orchestrationTime: `${Date.now() - startTime}ms`,
});
throw error;
}
}
/**
* Apply orchestration to determine optimal provider and model for streaming
* @param options - Original StreamOptions
* @returns Modified options with orchestrated provider marked in context, or empty object if validation fails
*/
async applyStreamOrchestration(options) {
const startTime = Date.now();
try {
// Ensure input.text exists before proceeding
if (!options.input?.text || typeof options.input.text !== "string") {
logger.debug("Stream orchestration skipped - no valid input text", {
hasInput: !!options.input,
hasText: !!options.input?.text,
textType: typeof options.input?.text,
});
return {}; // Return empty object to preserve existing fallback behavior
}
// Compute classification once to avoid duplicate calls
const classification = BinaryTaskClassifier.classify(options.input.text);
// Use the model router to get the optimal route
const route = ModelRouter.route(options.input.text);
// Validate that the routed provider is available and configured
const isProviderAvailable = await this.hasProviderEnvVars(route.provider);
if (!isProviderAvailable && route.provider !== "ollama") {
logger.debug("Stream orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: "Provider not configured or missing environment variables",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
// For Ollama, check if service is running and model is available
if (route.provider === "ollama") {
try {
const response = await fetch("http://localhost:11434/api/tags", {
method: "GET",
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
logger.debug("Stream orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: "Ollama service not responding",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
const responseData = await response.json();
const models = responseData?.models;
// Runtime-safe guard: ensure models is an array with valid objects
if (!Array.isArray(models)) {
logger.warn("Ollama API returned invalid models format in stream", {
responseData,
modelsType: typeof models,
});
return {}; // Return empty object for fallback behavior
}
// Filter and validate models before comparison
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
const targetModel = route.model;
if (targetModel) {
// Check if the specific routed model is available
const modelIsAvailable = validModels.some((m) => m.name === targetModel);
if (!modelIsAvailable) {
logger.debug("Stream orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: `Ollama model '${targetModel}' not found`,
orchestrationTime: `${Date.now() - startTime}ms`,
});
// Fall back to first available model instead of abandoning orchestration
if (validModels.length > 0) {
route.model = validModels[0].name;
}
else {
return {}; // No models at all — preserve existing fallback behavior
}
}
}
else if (validModels.length === 0) {
// No model specified and none available
logger.debug("Stream orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: "No Ollama models available",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
else {
// No model specified but models are available — use the first one
route.model = validModels[0].name;
}
}
catch (error) {
logger.debug("Stream orchestration provider validation failed", {
taskType: classification.type,
routedProvider: route.provider,
routedModel: route.model,
reason: error instanceof Error
? error.message
: "Ollama service check failed",
orchestrationTime: `${Date.now() - startTime}ms`,
});
return {}; // Return empty object to preserve existing fallback behavior
}
}
logger.debug("Stream orchestration route determined", {
taskType: classification.type,
selectedProvider: route.provider,
selectedModel: route.model,
confidence: route.confidence,
reasoning: route.reasoning,
orchestrationTime: `${Date.now() - startTime}ms`,
});
// Mark preferred provider in context instead of directly setting provider
// This preserves global fallback behavior while indicating orchestration preference
return {
model: route.model,
context: {
...(options.context || {}),
__orchestratedPreferredProvider: route.provider,
},
};
}
catch (error) {
logger.error("Stream orchestration failed", {
error: error instanceof Error ? error.message : String(error),
orchestrationTime: `${Date.now() - startTime}ms`,
});
throw error;
}
}
/**
* MAIN ENTRY POINT: Enhanced generate method with new function signature
* Replaces both generateText and legacy methods
*/
/**
* Extracts the original prompt text from the provided input.
* If a string is provided, it returns the string directly.
* If a GenerateOptions object is provided, it returns the input text from the object.
* @param optionsOrPrompt The prompt input, either as a string or a GenerateOptions object.
* @returns The original prompt text as a string.
*/
_extractOriginalPrompt(optionsOrPrompt) {
if (typeof optionsOrPrompt === "string") {
return optionsOrPrompt;
}
// Handle messages format (for workflow compatibility)
const anyOptions = optionsOrPrompt;
if (anyOptions.messages && anyOptions.messages.length > 0) {
const lastMessage = anyOptions.messages[anyOptions.messages.length - 1];
return stringifyContentSafe(lastMessage.content);
}
// Handle input.text format
return optionsOrPrompt.input?.text || "";
}
/**
* Generate AI content using the best available provider with MCP tool integration.
* This is the primary method for text generation with full feature support.
*
* @param optionsOrPrompt - Either a string prompt or a comprehensive GenerateOptions object
* @param optionsOrPrompt.input - Input configuration object
* @param optionsOrPrompt.input.text - The text prompt to send to the AI (required)
* @param optionsOrPrompt.provider - AI provider to use ('auto', 'openai', 'anthropic', etc.)
* @param optionsOrPrompt.model - Specific model to use (e.g., 'gpt-4', 'claude-3-opus')
* @param optionsOrPrompt.temperature - Randomness in response (0.0 = deterministic, 2.0 = very random)
* @param optionsOrPrompt.maxTokens - Maximum tokens in response
* @param optionsOrPrompt.systemPrompt - System message to set AI behavior
* @param optionsOrPrompt.disableTools - Whether to disable MCP tool usage
* @param optionsOrPrompt.enableAnalytics - Whether to include usage analytics
* @param optionsOrPrompt.enableEvaluation - Whether to include response quality evaluation
* @param optionsOrPrompt.context - Additional context for the request
* @param optionsOrPrompt.evaluationDomain - Domain for specialized evaluation
* @param optionsOrPrompt.toolUsageContext - Context for tool usage decisions
*
* @returns Promise resolving to GenerateResult with content, usage data, and optional analytics
*
* @example
* ```typescript
* // Simple usage with string prompt
* const result = await neurolink.generate("What is artificial intelligence?");
* console.log(result.content);
*
* // Advanced usage with options
* const result = await neurolink.generate({
* input: { text: "Explain quantum computing" },
* provider: "openai",
* model: "gpt-4",
* temperature: 0.7,
* maxTokens: 500,
* enableAnalytics: true,
* enableEvaluation: true,
* context: { domain: "science", level: "intermediate" }
* });
*
* // Access analytics and evaluation data
* console.log(result.analytics?.usage);
* console.log(result.evaluation?.relevance);
* ```
*
* @throws {Error} When input text is missing or invalid
* @throws {Error} When all providers fail to generate content
* @throws {Error} When conversation memory operations fail (if enabled)
*/
/**
* Get observability configuration
*/
getObservabilityConfig() {
return this.observabilityConfig;
}
/**
* Check if Langfuse telemetry is enabled
* Centralized utility to avoid duplication across providers
*/
isTelemetryEnabled() {
// Check if observability config enables telemetry
if (this.observabilityConfig?.langfuse?.enabled) {
return true;
}
// Check if OpenTelemetry was initialized (by this or external app)
return isOpenTelemetryInitialized();
}
/**
* Get comprehensive telemetry status including Langfuse, OTel, and exporter health
*/
getTelemetryStatus() {
const langfuseConfig = this.observabilityConfig?.langfuse;
const otelConfig = this.observabilityConfig?.openTelemetry;
return {
enabled: this.isTelemetryEnabled(),
langfuse: langfuseConfig
? {
enabled: langfuseConfig.enabled ?? false,
baseUrl: langfuseConfig.baseUrl,
environment: langfuseConfig.environment,
}
: undefined,
openTelemetry: otelConfig
? {
enabled: otelConfig.enabled ?? false,
endpoint: otelConfig.endpoint,
serviceName: otelConfig.serviceName,
}
: isOpenTelemetryInitialized() ||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT
? {
enabled: isOpenTelemetryInitialized(),
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
serviceName: process.env.OTEL_SERVICE_NAME,
}
: undefined,
exporters: [],
};
}
/**
* Get aggregated observability metrics (latency, tokens, cost, success rate)
*/
getMetrics() {
return this.metricsAggregator.getMetrics();
}
/**
* Get all recorded spans
*/
getSpans() {
return this.metricsAggregator.getSpans();
}
/**
* Get traces (spans grouped by traceId with parent-child hierarchy)
*/
getTraces() {
return this.metricsAggregator.getTraces();
}
/**
* Reset all collected metrics and spans
*/
resetMetrics() {
this.metricsAggregator.reset();
}
/**
* Record a span for metrics tracking
*/
recordMetricsSpan(span) {
this.metricsAggregator.recordSpan(span);
}
/**
* Get provider metrics analysis
* Retrieves aggregated performance, token usage, latency, and success rates per provider.
*
* @param options - Filtering options
* @returns Comprehensive provider metrics result
*/
async getProviderMetrics(options) {
// Propagate unexpected failures — empty datasets are returned by the
// service as valid zeroed results; fabricating those here hid real bugs.
return this.analyticsService.getProviderMetrics(options);
}
/**
* Get cost analysis breakdown
* Analyzes AI generation costs across requested groups and provides future projections.
*
* @param options - Cost configuration options
* @returns Detailed cost analysis breakdown
*/
async getCostAnalysis(options) {
return this.analyticsService.getCostAnalysis(options);
}
/**
* Get team-wide usage analytics
* Retrieves request counts, unique active users, provider breakdown, and quality scoring.
*
* @param options - Team query options
* @returns Comprehensive team analytics report
*/
async getTeamAnalytics(options) {
return this.analyticsService.getTeamAnalytics(options);
}
/**
* Record a memory operation span to both instance and global metrics aggregators.
* This ensures memory spans are visible via sdk.getSpans() and getMetricsAggregator().getSpans().
*/
recordMemorySpan(operationName, attributes, durationMs, status, statusMessage) {
const traceCtx = this._metricsTraceContext;
const span = SpanSerializer.createSpan(SpanType.MEMORY, operationName, attributes, traceCtx?.parentSpanId, traceCtx?.traceId);
span.durationMs = durationMs;
const endedSpan = SpanSerializer.endSpan(span, status);
if (statusMessage) {
endedSpan.statusMessage = statusMessage;
}
this.metricsAggregator.recordSpan(endedSpan);
try {
getMetricsAggregator().recordSpan(endedSpan);
}
catch {
/* ignore */
}
}
/**
* Public method to initialize Langfuse observability
* This method can be called externally to ensure Langfuse is properly initialized
*/
async initializeLangfuseObservability() {
try {
const langfuseConfig = this.observabilityConfig?.langfuse;
if (langfuseConfig?.enabled) {
try {
await initializeOpenTelemetry(langfuseConfig);
}
catch (err) {
logger.error("[NeuroLink] OpenTelemetry initialization failed", {
error: err instanceof Error ? err.message : String(err),
});
return;
}
logger.debug("[NeuroLink] Langfuse observability initialized via public method");
}
else {
logger.debug("[NeuroLink] Langfuse not enabled, skipping initialization");
}
}
catch (error) {
logger.warn("[NeuroLink] Failed to initialize Langfuse observability:", error);
}
}
/**
* Gracefully shutdown NeuroLink and all MCP connections
*/
async shutdown() {
try {
logger.debug("[NeuroLink] Starting graceful shutdown");
// Kill host-owned background work first: a shutdown must not leave
// child processes or delegate workers running with nobody to collect
// them.
try {
await killAllCommands(this);
await cancelBackgroundDelegates(this);
logger.debug("[NeuroLink] Background commands and delegates stopped");
}
catch (error) {
logger.warn("[NeuroLink] Background work cleanup failed:", error);
}
try {
await flushOpenTelemetry();
await shutdownOpenTelemetry();
logger.debug("[NeuroLink] OpenTelemetry shutdown completed");
}
catch (error) {
logger.warn("[NeuroLink] OpenTelemetry shutdown failed:", error);
}
if (this.externalServerManager) {
try {
await this.externalServerManager.shutdown();
logger.debug("[NeuroLink] MCP servers shutdown completed");
}
catch (error) {
logger.warn("[NeuroLink] MCP servers shutdown failed:", error);
}
}
// Shutdown TaskManager
if (this._taskManager) {
try {
await withTimeout(this._taskManager.shutdown(), 5000, new Error("TaskManager shutdown timed out"));
}
catch (error) {
logger.warn("[NeuroLink] TaskManager shutdown error:", error);
}
finally {
this._taskManager = undefined;
}
}
// Release the artifact store this instance built (a pooled Redis
// connection, for one). An injected store is the caller's to close.
if (this.ownsArtifactStore && this.mcpArtifactStore?.close) {
try {
await this.mcpArtifactStore.close();
logger.debug("[NeuroLink] Artifact store closed");
}
catch (error) {
logger.warn("[NeuroLink] Artifact store close failed:", error);
}
}
// Close conversation memory manager (release Redis connections, etc.)
if (this.conversationMemory?.close) {
try {
await this.conversationMemory.close();
logger.debug("[NeuroLink] Conversation memory shutdown completed");
}
catch (error) {
logger.warn("[NeuroLink] Conversation memory shutdown failed:", error);
}
}
logger.debug("[NeuroLink] Graceful shutdown completed");
this.credentials = undefined;
}
catch (error) {
logger.error("[NeuroLink] Shutdown failed:", error);
throw error;
}
}
/**
* Fire-and-forget analytics tracking with rejection logging.
*/
enqueueAnalyticsTrackRequest(record) {
this.analyticsService.trackRequest(record).catch((error) => {
logger.debug("[NeuroLink] analytics trackRequest failed", {
error: error instanceof Error ? error.message : String(error),
});
});
}
/**
* Estimate cost using the canonical SDK pricing table (same source as
* AnalyticsService.trackRequest) — avoids diverging TokenTracker rates.
*/
estimateCostFromUsage(provider, model, usage) {
if (!usage || model === "unknown") {
return undefined;
}
const totalCost = calculateAdvancedCost(model, usage.input || 0, usage.output || 0, provider === "unknown" ? undefined : provider, {
cacheReadTokens: usage.cacheReadTokens,
cacheCreationTokens: usage.cacheCreationTokens,
});
return totalCost > 0 ? totalCost : undefined;
}
/**
* Handle generation:end for Pipeline B spans + advanced analytics.
* When pipelineAHandled is set, skips Pipeline B spans but still tracks
* analytics so successful AI-SDK generate/stream calls are recorded.
* Stream analytics are owned here (not stream:complete) to avoid duplicates.
*/
handleGenerationEndMetrics(data) {
const skipPipelineBSpan = data.pipelineAHandled === true;
try {
const result = data.result;
const usage = result?.usage;
const analytics = result?.analytics;
const provider = data.provider || result?.provider || "unknown";
const model = result?.model || data.model || "unknown";
const responseTime = data.responseTime || 0;
const eventTimestamp = typeof data.timestamp === "number" && Number.isFinite(data.timestamp)
? data.timestamp
: Date.now();
const traceCtx = this._metricsTraceContext;
let computedCost = typeof analytics?.cost === "number" && Number.isFinite(analytics.cost)
? analytics.cost
: undefined;
if (!skipPipelineBSpan) {
let span = SpanSerializer.createGenerationSpan({
provider,
model,
name: `gen_ai.${provider}.chat`,
traceId: traceCtx?.traceId,
input: data.prompt,
temperature: data.temperature,
maxTokens: data.maxTokens,
});
if (traceCtx) {
span.parentSpanId = traceCtx.parentSpanId;
}
let spanStatus;
let statusMessage;
if (data.aborted === true) {
spanStatus = SpanStatus.WARNING;
statusMessage = "Generation aborted by client";
}
else if (data.success === false || data.error) {
spanStatus = SpanStatus.ERROR;
statusMessage = data.error ? String(data.error) : undefined;
}
else {
spanStatus = SpanStatus.OK;
}
span = SpanSerializer.endSpan(span, spanStatus, statusMessage);
span.durationMs = responseTime;
const finishReason = result?.finishReason ??
data.finishReason;
if (finishReason) {
span.attributes["gen_ai.finish_reason"] = finishReason;
if (finishReason === "content-filter" || finishReason === "length") {
span = SpanSerializer.endSpan(span, SpanStatus.WARNING, `Generation stopped: finishReason=${finishReason}`);
}
}
if (data.retryCount !== undefined) {
span.attributes["gen_ai.retry_count"] = data.retryCount;
}
if (usage) {
span = SpanSerializer.enrichWithTokenUsage(span, {
promptTokens: usage.input || 0,
completionTokens: usage.output || 0,
totalTokens: usage.total || (usage.input || 0) + (usage.output || 0),
});
}
if (analytics?.cost && analytics.cost > 0) {
span = SpanSerializer.enrichWithCost(span, {
totalCost: analytics.cost,
});
}
else {
const estimated = this.estimateCostFromUsage(provider, model, usage);
if (estimated !== undefined) {
span = SpanSerializer.enrichWithCost(span, {
totalCost: estimated,
});
}
}
const content = result?.content || result?.text;
if (content) {
span = SpanSerializer.updateAttributes(span, {
output: content.length > 5000
? content.substring(0, 5000) + "...[truncated]"
: content,
});
}
this.metricsAggregator.recordSpan(span);
getMetricsAggregator().recordSpan(span);
const spanCost = span.attributes["ai.cost.total"];
if (typeof spanCost === "number" && spanCost > 0) {
computedCost = spanCost;
}
this.enqueueAnalyticsTrackRequest({
provider,
model,
userId: data.userId || data.user || undefined,
teamId: data.teamId || undefined,
department: data.department || undefined,
timestamp: eventTimestamp,
latency: responseTime,
inputTokens: usage?.input || 0,
outputTokens: usage?.output || 0,
totalTokens: usage?.total || (usage?.input || 0) + (usage?.output || 0),
cost: computedCost,
isError: span.status === SpanStatus.ERROR,
errorMessage: statusMessage,
qualityScore: parseAnalyticsQualityScore(data.qualityScore),
});
}
else {
// Pipeline A handled the observation — still record analytics so
// successful AI-SDK generate()/stream() calls are not silently dropped.
if (computedCost === undefined) {
computedCost = this.estimateCostFromUsage(provider, model, usage);
}
this.enqueueAnalyticsTrackRequest({
provider,
model,
userId: data.userId || data.user || undefined,
teamId: data.teamId || undefined,
department: data.department || undefined,
timestamp: eventTimestamp,
latency: responseTime,
inputTokens: usage?.input || 0,
outputTokens: usage?.output || 0,
totalTokens: usage?.total || (usage?.input || 0) + (usage?.output || 0),
cost: computedCost,
isError: data.success === false || Boolean(data.error),
errorMessage: data.error ? String(data.error) : undefined,
qualityScore: parseAnalyticsQualityScore(data.qualityScore),
});
}
}
catch {
// Non-blocking
}
}
/**
* Pipeline B span recording for stream:complete.
* Advanced analytics are recorded from generation:end only (avoids duplicates
* when both stream:complete and generation:end fire for the same request).
*/
handleStreamCompleteMetrics(data) {
try {
const metadata = data.metadata;
const durationMs = metadata?.durationMs || 0;
const chunkCount = metadata?.chunkCount || 0;
const totalLength = metadata?.totalLength || 0;
const provider = data.provider || "unknown";
const model = data.model || "unknown";
const traceCtx = this._metricsTraceContext;
let span = SpanSerializer.createGenerationSpan({
provider,
model,
name: `gen_ai.${provider}.stream`,
traceId: traceCtx?.traceId,
});
if (traceCtx) {
span.parentSpanId = traceCtx.parentSpanId;
}
span = SpanSerializer.endSpan(span, SpanStatus.OK);
span.durationMs = durationMs;
span.attributes["stream.chunk_count"] = chunkCount;
span.attributes["stream.content_length"] = totalLength;
const streamFinishReason = metadata?.finishReason ??
data.finishReason;
if (streamFinishReason) {
span.attributes["gen_ai.finish_reason"] = streamFinishReason;
if (streamFinishReason === "content-filter" ||
streamFinishReason === "length") {
span = SpanSerializer.endSpan(span, SpanStatus.WARNING, `Stream stopped: finishReason=${streamFinishReason}`);
}
}
if (data.prompt) {
const promptStr = String(data.prompt);
span = SpanSerializer.updateAttributes(span, {
input: promptStr.length > 5000
? promptStr.substring(0, 5000) + "...[truncated]"
: promptStr,
});
}
const streamContent = data.content;
if (streamContent) {
span = SpanSerializer.updateAttributes(span, {
output: streamContent.length > 5000
? streamContent.substring(0, 5000) + "...[truncated]"
: streamContent,
});
}
const usage = metadata?.usage;
if (usage) {
span = SpanSerializer.enrichWithTokenUsage(span, {
promptTokens: usage.input || 0,
completionTokens: usage.output || 0,
totalTokens: usage.total || (usage.input || 0) + (usage.output || 0),
});
const estimated = this.estimateCostFromUsage(provider, model, usage);
if (estimated !== undefined) {
span = SpanSerializer.enrichWithCost(span, { totalCost: estimated });
}
}
this.metricsAggregator.recordSpan(span);
getMetricsAggregator().recordSpan(span);
}
catch {
// Non-blocking
}
}
handleToolEndMetrics(data) {
try {
const toolName = data.toolName || data.tool || "unknown";
const responseTime = data.responseTime || data.duration || 0;
const success = data.success !== undefined ? data.success : !data.error;
const traceCtx = this._metricsTraceContext;
let span = SpanSerializer.createSpan(SpanType.TOOL_CALL, `tool.${toolName}`, {
"tool.name": toolName,
"tool.success": success,
}, traceCtx?.parentSpanId, traceCtx?.traceId);
span = SpanSerializer.endSpan(span, success ? SpanStatus.OK : SpanStatus.ERROR);
span.durationMs = responseTime;
if (!success) {
if (data.error) {
span.statusMessage = String(data.error);
}
else if (data.result) {
span.statusMessage = extractMcpErrorText(data.result);
}
}
if (data.result) {
try {
span.attributes["tool.result"] = JSON.stringify(data.result).substring(0, 500);
}
catch {
// Non-blocking
}
}
this.metricsAggregator.recordSpan(span);
getMetricsAggregator().recordSpan(span);
}
catch {
// Non-blocking
}
}
/**
* Pipeline B span recording for stream:error.
* Analytics for failed streams come from generation:end (success: false).
*/
handleStreamErrorMetrics(data) {
try {
const metadata = data.metadata;
const durationMs = metadata?.durationMs || 0;
const chunkCount = metadata?.chunkCount || 0;
const errorName = metadata?.errorName || "UnknownError";
const errorMessage = data.content || "Stream error";
const provider = data.provider || "unknown";
const model = data.model || "unknown";
const traceCtx = this._metricsTraceContext;
let span = SpanSerializer.createGenerationSpan({
provider,
model,
name: `gen_ai.${provider}.stream.error`,
traceId: traceCtx?.traceId,
});
if (traceCtx) {
span.parentSpanId = traceCtx.parentSpanId;
}
span = SpanSerializer.endSpan(span, SpanStatus.ERROR);
span.durationMs = durationMs;
span.statusMessage = `${errorName}: ${errorMessage}`;
span.attributes["stream.chunk_count"] = chunkCount;
const isAbort = errorName === "AbortError" ||
errorMessage.toLowerCase().includes("aborted") ||
errorMessage.toLowerCase().includes("abort");
span.attributes["error.type"] = isAbort ? "abort" : errorName;
if (isAbort) {
span.attributes["stream.aborted"] = true;
}
this.metricsAggregator.recordSpan(span);
getMetricsAggregator().recordSpan(span);
}
catch {
// Non-blocking
}
}
/**
* Initialize event listeners that feed span data to MetricsAggregator.
* Listens to generation:end, stream:complete, and tool:end events.
*/
initializeMetricsListeners() {
this.emitter.on("generation:end", ((...args) => {
this.handleGenerationEndMetrics(args[0]);
}));
this.emitter.on("stream:complete", ((...args) => {
this.handleStreamCompleteMetrics(args[0]);
}));
this.emitter.on("tool:end", ((...args) => {
this.handleToolEndMetrics(args[0]);
}));
this.emitter.on("stream:error", ((...args) => {
this.handleStreamErrorMetrics(args[0]);
}));
}
/**
* Generate AI response with comprehensive feature support.
*
* Primary method for AI generation with support for all NeuroLink features:
* - Multi-provider support
* - MCP tool integration
* - Structured JSON output with Zod schemas
* - Conversation memory (Redis or in-memory)
* - HITL security workflows
* - Middleware execution
* - Multimodal inputs (images, PDFs, CSV)
*
* @category Generation
*
* @param optionsOrPrompt - Generation options or simple text prompt
* @param optionsOrPrompt.input - Input text and optional files
* @param optionsOrPrompt.provider - AI provider name (e.g., 'vertex', 'openai', 'anthropic')
* @param optionsOrPrompt.model - Model name to use
* @param optionsOrPrompt.tools - MCP tools to enable for this generation
* @param optionsOrPrompt.schema - Zod schema for structured output validation
* @param optionsOrPrompt.temperature - Sampling temperature (0-2, default: 1.0)
* @param optionsOrPrompt.maxTokens - Maximum tokens to generate
* @param optionsOrPrompt.thinkingConfig - Extended thinking configuration (thinkingLevel: 'minimal'|'low'|'medium'|'high')
* @param optionsOrPrompt.context - Context with conversationId and userId for memory
* @param optionsOrPrompt.useKnowledgeGrounding - Whether to use the instance's configured knowledge for this call
* @returns Promise resolving to generation result with content and metadata
*
* @example Basic text generation
* ```typescript
* const result = await neurolink.generate({
* input: { text: 'Explain quantum computing' }
* });
* console.log(result.content);
* ```
*
* @example With specific provider
* ```typescript
* const result = await neurolink.generate({
* input: { text: 'Write a poem' },
* provider: 'anthropic',
* model: 'claude-3-opus'
* });
* ```
*
* @example With MCP tools
* ```typescript
* const result = await neurolink.generate({
* input: { text: 'Read README.md and summarize it' },
* tools: ['readFile']
* });
* ```
*
* @example With structured output
* ```typescript
* import { z } from 'zod';
*
* const schema = z.object({
* name: z.string(),
* age: z.number(),
* city: z.string()
* });
*
* const result = await neurolink.generate({
* input: { text: 'Extract person info: John is 30 years old from NYC' },
* schema: schema
* });
* // result.structuredData is type-safe!
* ```
*
* @example With conversation memory
* ```typescript
* const result = await neurolink.generate({
* input: { text: 'What did we discuss earlier?' },
* context: {
* conversationId: 'conv-123',
* userId: 'user-456'
* }
* });
* ```
*
* @example With multimodal input
* ```typescript
* const result = await neurolink.generate({
* input: {
* text: 'Describe this image',
* images: ['/path/to/image.jpg']
* },
* provider: 'vertex'
* });
* ```
*
* @throws {Error} When input text is missing or invalid
* @throws {Error} When all providers fail to generate content
* @throws {Error} When structured output validation fails
* @throws {Error} When HITL approval is denied
*
* @see {@link GenerateOptions} for all available options
* @see {@link GenerateResult} for result structure
* @see {@link stream} for streaming generation
* @since 1.0.0
*/
async generate(optionsOrPrompt) {
return logger.runInInstanceScope(this.logInstanceId, () => this.generateInInstanceScope(optionsOrPrompt));
}
/**
* Body of {@link generate}, always entered through the instance log scope
* so every log call beneath it is attributable to this instance.
*/
async generateInInstanceScope(optionsOrPrompt) {
// Host-loop delegation (registerAgentTool): enter a per-turn scope so
// delegation caps count against THIS top-level generate, and withhold
// depth-limited agent tools from the request. beginDelegationTurn
// returns null when a scope is already active, so the re-entrant call
// below proceeds through the normal body sharing the turn's counters.
if (this.hasAgentTools) {
const { beginDelegationTurn } = await import("./agent/agentToolRegistrar.js");
const scope = beginDelegationTurn(this, optionsOrPrompt);
if (scope) {
return scope.run(() => this.generate(scope.options));
}
}
// Defensive call-isolation clone — mirrors stream(): downstream
// generate-prep (memory retrieval, orchestration, RAG/MCP tool
// injection) mutates nested branches on the caller-supplied options
// object. Without cloning here, callers reusing a single options
// bag across generate() calls accumulate state across them.
// String prompts are immutable, so they pass through.
if (typeof optionsOrPrompt !== "string") {
optionsOrPrompt = cloneOptionsForCallIsolation(optionsOrPrompt);
// Resolved once per call and always overwrites a caller-supplied
// policy; an invalid root fails here, before any model call.
bindFileToolRoots(optionsOrPrompt, this.resolveFileToolRoots(optionsOrPrompt.toolRoots));
// The deprecated `conversationHistory` field is not wired into message
// building — messages passed there never reach the model, which reads
// as "the SDK forgot my context" rather than a caller bug. Warn loudly
// instead of failing silently; `conversationMessages` is the wired path.
const legacyOpts = optionsOrPrompt;
if (Array.isArray(legacyOpts.conversationHistory) &&
legacyOpts.conversationHistory.length > 0 &&
!legacyOpts.conversationMessages) {
logger.warn("[NeuroLink.generate] `conversationHistory` is deprecated and NOT passed to the model — use `conversationMessages` (ChatMessage[]) instead.");
}
}
// Resolved once here and removed from the options, so no internal
// re-entrant generate() can prepend the agent instructions twice.
let agentModeVersion;
if (typeof optionsOrPrompt !== "string") {
const { agentMode, ...rest } = optionsOrPrompt;
if (agentMode !== undefined) {
const resolved = resolveTerminalAgentMode(agentMode, rest.systemPrompt);
agentModeVersion = resolved.version;
optionsOrPrompt = {
...rest,
systemPrompt: resolved.systemPrompt,
};
}
}
// Retrieve once at the public call boundary so fallback attempts reuse the
// same grounding block and internal preparation cannot inject it twice.
const groundingOptions = typeof optionsOrPrompt === "string"
? { input: { text: optionsOrPrompt } }
: optionsOrPrompt;
const knowledgeOutcome = await this.retrieveKnowledgeGrounding(groundingOptions);
if (knowledgeOutcome?.ephemeralContext) {
const block = knowledgeOutcome.ephemeralContext.content;
optionsOrPrompt = {
...groundingOptions,
systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(groundingOptions.systemPrompt, block),
};
}
const startedAt = Date.now();
try {
const result = await this.runWithFallbackOrchestration(optionsOrPrompt, "generate", (opts) => {
// Capture root-ness before startActiveSpan makes generateSpan active.
// The actual guest-rescue stamp is deferred to executeGenerateRequest,
// AFTER prepareGenerateRequest merges auth/requestContext-derived
// identity into options.context — otherwise an auth:{token} caller
// with no pre-set context.userId would stamp the root span as guest.
const generateIsRoot = !trace.getSpan(context.active());
return tracers.sdk.startActiveSpan("neurolink.generate", { kind: SpanKind.INTERNAL }, (generateSpan) => this.executeGenerateWithMetricsContext(opts, generateSpan, generateIsRoot));
});
if (knowledgeOutcome) {
result.knowledge = knowledgeOutcome.metadata;
}
if (agentModeVersion) {
result.agentModeVersion = agentModeVersion;
}
return result;
}
catch (error) {
// Lifecycle middleware (wrapGenerate.catch in builtin/lifecycle.ts)
// stamps errors it already surfaced with the shared Symbol marker
// (see utils/lifecycleCallbacks.ts). For errors thrown BEFORE the
// language model was wrapped (e.g. unknown provider name,
// validation failures, factory exceptions), the mark is absent —
// fire the consumer's onError here so it sees every failure path.
// Awaited so async handlers fully settle before generate()
// rethrows, matching the middleware-managed path.
await this.fireConsumerOnErrorIfNotFired(optionsOrPrompt, error, startedAt);
throw error;
}
}
async fireConsumerOnErrorIfNotFired(optionsOrPrompt, error, startedAt) {
if (hasLifecycleErrorFired(error)) {
return;
}
if (typeof optionsOrPrompt !== "object" || optionsOrPrompt === null) {
return;
}
const opts = optionsOrPrompt;
const userOnError = opts.onError;
if (!userOnError) {
return;
}
const err = error instanceof Error ? error : new Error(String(error));
// Bound the consumer callback so a never-settling handler can't hang
// generate()/stream(). The deadline honors per-call
// `lifecycle.timeoutMs` and the `NEUROLINK_LIFECYCLE_TIMEOUT_MS` env
// var (CLI surface), falling back to 5_000. Errors raised here are
// logged and swallowed; the original failure still propagates to the
// caller because the outer catch re-throws.
const lifecycle = opts.middleware?.middlewareConfig?.lifecycle?.config;
const timeoutMs = resolveLifecycleTimeoutMs(lifecycle);
// Mark the error first so the AI-SDK lifecycle middleware can't
// re-fire the same callback if the throw races a parallel catch.
markLifecycleErrorFired(err);
try {
await withTimeout(Promise.resolve(userOnError({
error: err,
duration: Date.now() - startedAt,
recoverable: false,
})), timeoutMs, new Error(`consumer onError callback timed out after ${timeoutMs}ms`));
}
catch (e) {
logger.warn("[NeuroLink] consumer onError callback error:", e);
}
}
/**
* Curator P2-3: wraps a generate/stream call with the fallback
* orchestration (`providerFallback` callback + `modelChain` walker).
*
* On a qualifying error from the inner call:
* 1. Resolve the effective callback (per-call > instance > synthesised
* from modelChain) and the effective chain (per-call > instance).
* 2. Walk attempts: invoke callback (or pop next chain entry) → emit
* `model.fallback` event → re-call inner with the new {provider,
* model}.
* 3. Stop on first success, on a callback returning null, or after
* exhausting the chain (throw the most recent error).
*
* What qualifies depends on how fallback was configured: an EXPLICIT
* `providerFallback` callback is consulted for any error except client
* aborts (the callback owns the decision — it receives the error
* unmodified and can return null to bubble), while modelChain-only
* configs keep the narrow model-access-denied gate.
*/
async runWithFallbackOrchestration(optionsOrPrompt, kind, inner) {
const initialAttempt = await this.attemptInner(inner, optionsOrPrompt);
if ("ok" in initialAttempt) {
return initialAttempt.ok;
}
let lastError = initialAttempt.error;
// Resolve the fallback configuration BEFORE gating so the gate can
// distinguish an explicit callback from a modelChain-only setup.
const requestedProvider = (typeof optionsOrPrompt === "object"
? optionsOrPrompt.provider
: undefined);
const requestedModel = (typeof optionsOrPrompt === "object"
? optionsOrPrompt.model
: undefined);
const callOpts = typeof optionsOrPrompt === "object"
? optionsOrPrompt
: {};
const perCallCallback = callOpts.providerFallback;
const perCallChain = callOpts.modelChain;
const effectiveCallback = perCallCallback ?? this.fallbackConfig.providerFallback;
const effectiveChain = perCallChain ?? this.fallbackConfig.modelChain;
// Explicit callback (per-call or instance providerFallback): the callback
// owns the decision for any error except genuine caller cancels — it can
// return null to bubble. modelChain-only keeps the narrow
// model-access-denied gate so chain walkers don't retry errors the chain
// can't fix.
//
// Error shape alone cannot identify a caller cancel: NeuroLink's own
// watchdog/timeout controllers abort the same composed signal, and SDKs
// normalize that into the identical AbortError shapes a user cancel
// produces (e.g. Anthropic's APIUserAbortError). Ground truth is the
// caller-supplied abortSignal itself — only when IT has fired is an
// abort-shaped error a real cancel; otherwise the abort was internally
// initiated (turn/stall watchdog, per-step timeout) and the callback
// must still be consulted so provider hangs can fall back. Read live
// (not captured) so the post-retry re-gate sees a cancel that arrives
// mid-fallback; cloneOptionsForCallIsolation keeps abortSignal
// by-reference, so the retried options observe the same signal.
const callerAborted = () => callOpts.abortSignal?.aborted === true;
// modelChain-only orchestration advances on two error shapes the next
// member can actually fix: access-denied (the original gate) and
// model-not-found ("Invalid model name" from a gateway that doesn't
// serve that member — a chain written for one LiteLLM deployment must
// not hard-fail on another just because a member is absent there).
const shouldOrchestrateFallback = (err) => effectiveCallback
? !(isAbortError(err) && callerAborted())
: looksLikeModelAccessDenied(err) || sharedLooksLikeModelNotFound(err);
if (!shouldOrchestrateFallback(lastError)) {
throw lastError;
}
if (!effectiveCallback && !effectiveChain) {
throw lastError;
}
// Synthesise a callback from modelChain if no explicit callback exists.
const chainCursor = { i: 0, list: effectiveChain ?? [] };
const synthesizedFromChain = async () => {
while (chainCursor.i < chainCursor.list.length) {
const next = chainCursor.list[chainCursor.i++];
if (next !== requestedModel) {
return { model: next };
}
}
return null;
};
const callback = effectiveCallback ?? synthesizedFromChain;
let attempts = 0;
const maxAttempts = (effectiveChain?.length ?? 0) + 5;
let attemptedRequestedModel = requestedModel;
while (attempts++ < maxAttempts) {
let next;
try {
next = await callback(lastError);
}
catch (cbErr) {
logger.warn("[NeuroLink] providerFallback callback threw", {
error: cbErr instanceof Error ? cbErr.message : String(cbErr),
});
throw lastError;
}
if (!next) {
throw lastError;
}
// Emit model.fallback event so cost/audit listeners can record it.
try {
this.emitter.emit("model.fallback", {
requestedProvider,
requestedModel: attemptedRequestedModel,
fallbackProvider: next.provider ?? requestedProvider,
fallbackModel: next.model,
reason: lastError instanceof Error ? lastError.message : String(lastError),
kind,
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
// Defensive call-isolation clone for the retry attempt. The shallow
// spread below would keep nested branches (`input`, `tools`, `memory`,
// `rag`, …) pointing at the same objects the previous attempt's
// prepare stages mutated — so the retry inherits e.g. memory-retrieved
// history appended to `input.messages`, RAG-injected tools, etc.
// Re-cloning at the retry boundary mirrors the entry-level isolation
// applied in generate()/stream() so each fallback attempt sees a
// fresh options bag.
const retriedOptions = typeof optionsOrPrompt === "object"
? cloneOptionsForCallIsolation({
...optionsOrPrompt,
...(next.provider && { provider: next.provider }),
...(next.model && { model: next.model }),
// Strip the fallback hooks so the retry doesn't re-orchestrate.
providerFallback: undefined,
modelChain: undefined,
})
: optionsOrPrompt;
const retryAttempt = await this.attemptInner(inner, retriedOptions);
if ("ok" in retryAttempt) {
return retryAttempt.ok;
}
lastError = retryAttempt.error;
attemptedRequestedModel = next.model ?? attemptedRequestedModel;
if (!shouldOrchestrateFallback(lastError)) {
throw lastError;
}
}
throw lastError;
}
async attemptInner(inner, options) {
try {
const ok = await inner(options);
return { ok };
}
catch (error) {
return { error };
}
}
async executeGenerateWithMetricsContext(optionsOrPrompt, generateSpan, isRootSpan) {
return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeGenerateRequest(optionsOrPrompt, generateSpan, isRootSpan));
}
async executeGenerateRequest(optionsOrPrompt, generateSpan, isRootSpan) {
let resolvedOptions;
try {
const { options, originalPrompt } = await this.prepareGenerateRequest(optionsOrPrompt, generateSpan);
resolvedOptions = options;
// Stamp now that prepareGenerateRequest has merged any auth/requestContext
// identity into options.context (see capture of isRootSpan in generate()).
stampGuestRescueIdentity(generateSpan, options.context, isRootSpan);
const earlyResult = await this.maybeHandleEarlyGenerateResult(options, generateSpan);
if (earlyResult) {
generateSpan.setStatus({ code: SpanStatusCode.OK });
return earlyResult;
}
// Pre-call tool routing for generate(): mirrors the stream() routing path.
// Runs inside the generate's Langfuse context (setLangfuseContextFromOptions)
// so the router's own generation span nests under this turn's trace.
// After the early-result short-circuit so workflow/media turns skip it.
const result = await this.setLangfuseContextFromOptions(options, async () => {
await this.applyToolRoutingExclusions(options, options.input?.text ?? "");
return this.runStandardGenerateRequest(options, originalPrompt, generateSpan);
});
generateSpan.setStatus({ code: SpanStatusCode.OK });
return result;
}
catch (error) {
// Match the inner-span discrimination: client aborts are user-initiated
// cancellations, not faults. Mark with finishReason=aborted and skip
// ERROR status so ContextEnricher routes the outer trace to
// langfuse.level=WARNING (matches Curator telemetry-gaps Issue 5a). All
// other errors keep the existing ERROR status + recordException pair.
if (isAbortError(error)) {
generateSpan.setAttribute("ai.finishReason", "aborted");
generateSpan.setAttribute("neurolink.aborted", true);
}
else {
generateSpan.recordException(error instanceof Error ? error : new Error(String(error)));
generateSpan.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
}
// G7 fix: Distinguish context overflow errors with dedicated attributes
if (error instanceof ContextBudgetExceededError) {
generateSpan.setAttribute("neurolink.error.type", "context_overflow");
generateSpan.setAttribute("neurolink.context.estimated_tokens", error.estimatedTokens);
generateSpan.setAttribute("neurolink.context.available_tokens", error.availableTokens);
}
this.emitGenerateErrorEvent((resolvedOptions ?? optionsOrPrompt), error);
throw error;
}
finally {
this._disableToolCacheForCurrentRequest = false;
this._toolCacheKeysServedThisRequest = new Set();
this._generationTurnActive = false;
generateSpan.end();
}
}
async prepareGenerateRequest(optionsOrPrompt, generateSpan) {
const originalPrompt = this._extractOriginalPrompt(optionsOrPrompt);
const options = typeof optionsOrPrompt === "string"
? { input: { text: optionsOrPrompt } }
: { ...optionsOrPrompt };
// Normalise: all downstream code assumes options.input is defined.
// Media-only callers may omit `input` entirely (or pass `input: {}`);
// synthesise an empty shell so the rest of the pipeline can rely on it.
if (!options.input) {
options.input = {};
}
// Dynamic argument resolution — resolve any function-valued options before downstream use
await this.resolveDynamicOptions(options);
options.model = resolveModel(options.model, this.modelAliasConfig);
this._disableToolCacheForCurrentRequest = !!options.disableToolCache;
this._toolCacheKeysServedThisRequest = new Set();
this._generationTurnActive = true;
generateSpan.setAttribute("neurolink.provider", options.provider || "default");
generateSpan.setAttribute("neurolink.model", options.model || "default");
generateSpan.setAttribute("neurolink.input_length", typeof optionsOrPrompt === "string"
? optionsOrPrompt.length
: options.input?.text?.length || 0);
generateSpan.setAttribute("neurolink.has_tools", !!(options.tools && Object.keys(options.tools).length > 0));
// Ensure options.input is always an object — callers may omit it for
// media-only modes (avatar / music / video) or STT flows. Downstream code
// accesses it unconditionally, so we guarantee a non-null object here and
// rely on the per-mode checks below to populate / validate the text field.
options.input ??= {};
// When STT audio is provided, ensure options.input.text is initialised
// (the transcription will supply the text inside runStandardGenerateRequest).
const hasSttAudio = !!(options.stt?.enabled && options.stt?.audio);
if (hasSttAudio && !options.input.text) {
options.input.text = "";
}
// Modality dispatch (video / avatar / music) carries the prompt inside
// `output.{video|avatar|music}` — input.text is not meaningful for these
// modes. Synthesize an empty input.text and skip validation, mirroring
// the STT-audio exception above.
const isMediaModalityMode = options.output?.mode === "video" ||
options.output?.mode === "avatar" ||
options.output?.mode === "music";
if (isMediaModalityMode && !options.input.text) {
options.input.text = "";
}
if (!hasSttAudio && !isMediaModalityMode) {
this.assertInputText(options.input?.text, "Input text is required and must be a non-empty string");
}
// Input validation (trimWhitespace, minLength, maxLength, requireContent).
// Guard on a string input.text — release's media-only / STT flows may leave
// it empty/undefined, and those never carry inputValidation anyway.
if (options.inputValidation && typeof options.input.text === "string") {
const iv = options.inputValidation;
if (iv.trimWhitespace) {
options.input.text = options.input.text.trim();
}
if (iv.requireContent && !options.input.text.trim()) {
throw new Error("Input content is required but was empty or whitespace");
}
if (iv.minLength && options.input.text.length < iv.minLength) {
throw new Error(`Input text is too short (${options.input.text.length} < ${iv.minLength})`);
}
if (iv.maxLength && options.input.text.length > iv.maxLength) {
throw new Error(`Input text is too long (${options.input.text.length} > ${iv.maxLength})`);
}
}
// PII detection and redaction
if (options.piiDetection?.enabled &&
typeof options.input.text === "string") {
const piiResult = await detectAndRedactPII(options.input.text, {
enabled: true,
action: options.piiDetection.action ?? "warn",
detectTypes: options.piiDetection.detectTypes,
customPatterns: options.piiDetection.customPatterns,
allowList: options.piiDetection.allowList,
redactionText: options.piiDetection.redactionText,
});
if (piiResult.action === "abort") {
throw new Error(piiResult.feedback ?? "Request blocked: PII detected in input");
}
// Replace input text with redacted version
options.input.text = piiResult.text;
}
this.enforceSessionBudget(options.maxBudgetUsd);
this.applyGenerateLifecycleMiddleware(options);
await this.applyAuthenticatedRequestContext(options);
return { options, originalPrompt };
}
async maybeHandleEarlyGenerateResult(options, generateSpan) {
if (options.workflow || options.workflowConfig) {
// Workflow engine operates on text; media generation modes (avatar, music,
// video, ppt) use dedicated code paths and are incompatible with workflows.
const workflowMediaMode = options.output?.mode;
if (workflowMediaMode === "avatar" ||
workflowMediaMode === "music" ||
workflowMediaMode === "video" ||
workflowMediaMode === "ppt") {
throw new Error(`Workflow mode is not compatible with output.mode="${workflowMediaMode}". ` +
'Remove the workflow config or use output.mode="text".');
}
if (options.stt?.enabled && options.stt?.audio) {
// prepareGenerateRequest synthesizes input.text = "" for audio-only
// calls, so without this guard generateWithWorkflow runs with an
// empty prompt. Fail fast when there's no text fallback.
if (!options.input?.text?.trim()) {
throw new Error("STT audio is not supported with workflow mode without input.text");
}
logger.warn("[NeuroLink] STT audio preprocessing is not supported with workflow mode; audio will be ignored");
}
return this.generateWithWorkflow(options);
}
// Single source of truth for "what kind of request is this" — see
// resolveRequestKind's doc comment for the full precedence table.
const requestKind = resolveRequestKind(options, options.model);
if (requestKind === "music") {
return this.generateWithMusic(options, generateSpan);
}
if (requestKind === "avatar") {
return this.generateWithAvatar(options, generateSpan);
}
if (requestKind !== "ppt") {
return null;
}
if (options.stt?.enabled && options.stt?.audio) {
// Same fail-fast as the workflow branch — see comment above.
if (!options.input?.text?.trim()) {
throw new Error("STT audio is not supported with PPT mode without input.text");
}
logger.warn("[NeuroLink] STT audio preprocessing is not supported with PPT mode; audio will be ignored");
}
const pptResult = await this.generateWithPPT(options);
generateSpan.setAttribute("neurolink.output_length", pptResult.content?.length ?? 0);
if (pptResult.analytics) {
generateSpan.setAttribute("neurolink.tokens.input", pptResult.analytics.tokenUsage?.input ?? 0);
generateSpan.setAttribute("neurolink.tokens.output", pptResult.analytics.tokenUsage?.output ?? 0);
generateSpan.setAttribute("neurolink.cost", pptResult.analytics.cost ?? 0);
}
generateSpan.setStatus({ code: SpanStatusCode.OK });
return pptResult;
}
async runStandardGenerateRequest(options, originalPrompt, generateSpan) {
const startTime = Date.now();
// Pre-call classifier router: opt-in. Classifies the request and selects a
// provider/model from the configured base pool (and optionally narrows the
// tool set) BEFORE orchestration/requestRouter, so it takes precedence.
// Fails open.
await this.applyClassifierRouting(options, options.input?.text ?? originalPrompt ?? "", !options.disableTools &&
(!!(options.tools && Object.keys(options.tools).length > 0) ||
this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options.thinkingConfig?.thinkingLevel);
await this.maybeApplyGenerateOrchestration(options);
// Pre-call request router: opt-in, only when the caller did not set both
// provider+model. Fails open (any router error leaves options unchanged).
await this.applyRequestRouter(options, options.input?.text ?? originalPrompt ?? "", !options.disableTools &&
(!!(options.tools && Object.keys(options.tools).length > 0) ||
this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options.thinkingConfig?.thinkingLevel);
this.emitter.emit("generation:start", {
provider: options.provider || "auto",
timestamp: startTime,
});
this.emitter.emit("response:start");
this.emitter.emit("message", `Starting ${options.provider || "auto"} text generation...`);
const factoryResult = processFactoryOptions(options);
if (factoryResult.hasFactoryConfig && options.factoryConfig) {
const validation = validateFactoryConfig(options.factoryConfig);
if (!validation.isValid) {
logger.warn("Invalid factory configuration detected", {
errors: validation.errors,
});
}
}
await this.prepareGenerateAugmentations(options);
const textOptions = await this.buildGenerateTextOptions(options, originalPrompt, factoryResult);
// STT preprocessing: transcribe audio input before LLM generation
let sttTranscription;
if (options.stt?.enabled && options.stt.audio) {
try {
// Always call — registerAllProviders() is idempotent via internal
// `registered` + `registrationPromise` deduplication. The previous
// isRegistered() guard short-circuited even when STT handler
// registration failed silently after AI providers were registered.
await ProviderRegistry.registerAllProviders();
const { STTProcessor } = await import("./utils/sttProcessor.js");
const sttProvider = options.stt.provider ?? "whisper";
sttTranscription = await STTProcessor.transcribe(options.stt.audio, sttProvider, options.stt);
// Inject transcription into the LLM prompt
if (sttTranscription.text) {
const existingText = textOptions.prompt || textOptions.input?.text || "";
if (!existingText) {
// No user text — use transcription directly as the prompt
textOptions.prompt = sttTranscription.text;
if (textOptions.input) {
textOptions.input.text = sttTranscription.text;
}
}
else {
// User provided text — prepend transcription as context
const combined = `[Transcribed audio]: ${sttTranscription.text}\n\n${existingText}`;
if (textOptions.prompt) {
textOptions.prompt = combined;
}
if (textOptions.input?.text) {
textOptions.input.text = combined;
}
}
}
}
catch (sttError) {
const existingText = textOptions.prompt || textOptions.input?.text || "";
if (!existingText) {
// Audio-only request — no text to fall back to, fail fast
throw sttError;
}
logger.warn(`[NeuroLink] STT transcription failed, falling back to text: ${sttError instanceof Error ? sttError.message : String(sttError)}`);
}
}
const textResult = await this.generateTextInternal(textOptions);
// For STT-only calls, originalPrompt was captured before transcription.
// Use the transcribed text as the effective prompt for telemetry, memory,
// and trace attribution so traces don't show empty prompts.
const effectiveOriginalPrompt = sttTranscription?.text
? originalPrompt
? `[Transcribed audio]: ${sttTranscription.text}\n\n${originalPrompt}`
: sttTranscription.text
: originalPrompt;
// Attach STT transcription to result
const generateResult = this.finalizeGenerateRequestResult({
generateSpan,
options,
textOptions,
textResult,
factoryResult,
originalPrompt: effectiveOriginalPrompt,
startTime,
});
if (sttTranscription) {
generateResult.transcription = sttTranscription;
}
return generateResult;
}
async maybeApplyGenerateOrchestration(options) {
if (!this.enableOrchestration || options.provider || options.model) {
return;
}
try {
const orchestratedOptions = await this.applyOrchestration(options);
logger.debug("Orchestration applied", {
originalProvider: options.provider || "auto",
orchestratedProvider: orchestratedOptions.provider,
orchestratedModel: orchestratedOptions.model,
prompt: (options.input?.text ?? "").substring(0, 100),
});
Object.assign(options, orchestratedOptions);
if (orchestratedOptions.model) {
options.model = resolveModel(options.model, this.modelAliasConfig);
}
}
catch (error) {
logger.warn("Orchestration failed, continuing with original options", {
error: error instanceof Error ? error.message : String(error),
originalProvider: options.provider || "auto",
});
}
}
/**
* Applies the host-configured `requestRouter` to `options` in place.
*
* The router is skipped when:
* - no `requestRouter` is configured on this instance, or
* - the caller explicitly set both `options.provider` AND `options.model`
* (we only skip the router if BOTH are set; a caller setting only one
* still lets the router fill in the other field).
*
* Fails open: any router error is logged at WARN level and the call
* continues with the original options unmodified.
*
* @param options — the mutable options object (generate or stream).
* @param promptText — the text prompt used to build the RouterInputContext.
* @param hasTools — true when at least one tool is available for this call.
* @param requiresVision — true when the call includes image attachments.
* @param thinkingLevel — optional thinking level string from the call options.
*/
async applyRequestRouter(options, promptText, hasTools, requiresVision, thinkingLevel) {
if (!this.requestRouter) {
return;
}
// Stand down if the classifier router already selected a provider/model on
// this turn — one explicit precedence order: classifierRouter wins.
if (options.context
?.__classifierRouted) {
return;
}
// Skip if the caller explicitly set both provider AND model — they know
// what they want; the router should not override an intentional choice.
if (options.provider && options.model) {
return;
}
// Skip when a ModelPool is also configured: the pool unconditionally
// overrides provider+model for each member it selects. Allowing the
// router to run first would set options.provider/model to a value the
// pool immediately clobbers, creating silent ownership confusion and
// making the pool bypass the "user didn't set provider+model" guard above
// on subsequent attempts.
if (this.modelPool) {
logger.debug("[NeuroLink] applyRequestRouter: skipped — modelPool takes precedence");
return;
}
const ctx = {
prompt: promptText,
// Cheap token estimate: ~4 chars/token.
estimatedInputTokens: Math.ceil(promptText.length / 4),
hasTools,
requiresVision,
thinkingLevel,
};
try {
const decision = await this.requestRouter(ctx);
// Apply provider/model/region as a coherent set: when the router supplies
// a provider (the caller didn't pin one), take its model and region too.
// If the caller pinned the provider but not the model, only apply the
// router's model when it targets the same provider.
if (decision.provider && !options.provider) {
options.provider = decision.provider;
// Adopt the router's model and region only when they belong to the
// provider the router just selected — never mix a router model onto a
// caller-pinned different provider.
if (decision.model && !options.model) {
options.model = decision.model;
}
if (decision.region && !options.region) {
options.region = decision.region;
}
}
else if (!decision.provider) {
// Router returned a no-op or model-only decision.
if (decision.model && !options.model) {
options.model = decision.model;
}
if (decision.region && !options.region) {
options.region = decision.region;
}
}
// If decision.provider differs from the caller-pinned options.provider,
// we skip the router result entirely to avoid an incoherent pairing.
if (decision.reason) {
logger.debug("[NeuroLink] requestRouter decision", {
reason: decision.reason,
provider: options.provider,
model: options.model,
});
}
}
catch (routerErr) {
logger.warn("[NeuroLink] requestRouter threw — proceeding unrouted", {
error: routerErr instanceof Error ? routerErr.message : String(routerErr),
});
}
}
/**
* Dependencies handed to every `ContextCompactor`.
*
* `tryDecide` returns null whenever no decision provider is configured or
* the call fails, so passing this unconditionally is safe: the compactor
* runs its previous pipeline unchanged and pays nothing for the option.
*/
contextCompactorDeps() {
return {
decide: (decisionOptions) => this.tryDecide(decisionOptions),
relevance: this.contextRelevanceOptions,
};
}
/**
* Applies the host-configured `classifierRouter` to `options` in place.
*
* Classifies the request by difficulty and selects a provider/model from the
* configured base pool (cheaper/faster for easy tasks, more capable for hard
* ones), and optionally narrows the tool set via `toolFilter`/`excludeTools`.
*
* Skipped when: no router is configured; the inbound call is the classifier's
* own generate() (marked `__classifierRouted`); the caller pinned BOTH
* provider and model; or a ModelPool is configured (the pool owns selection).
* Fails open — any error leaves options unchanged.
*/
async applyClassifierRouting(options, promptText, hasTools, requiresVision, thinkingLevel) {
if (!this.classifierRouter) {
return;
}
const opt = options;
// Prevent recursion: the LLM classifier itself calls generate() with this
// marker set. Also respect any explicit prior routing decision.
if (opt.context?.__classifierRouted) {
return;
}
// Honor an intentional caller choice of BOTH provider and model.
if (options.provider && options.model) {
return;
}
// A ModelPool overrides provider/model per member unconditionally; let it
// own selection (mirrors applyRequestRouter precedence).
if (this.modelPool) {
logger.debug("[NeuroLink] applyClassifierRouting: skipped — modelPool takes precedence");
return;
}
const sessionId = typeof opt.context?.sessionId === "string"
? opt.context.sessionId
: undefined;
// Routing runs before conversation memory is loaded, so only the history
// the caller passed in is countable here.
const sessionBound = sessionId !== undefined;
const priorMessageCount = Array.isArray(opt.conversationMessages)
? opt.conversationMessages.length
: undefined;
try {
const decision = await this.classifierRouter.route({
prompt: promptText,
estimatedInputTokens: Math.ceil((promptText?.length ?? 0) / 4),
hasTools,
requiresVision,
thinkingLevel,
sessionId,
sessionBound,
priorMessageCount,
});
if (!decision) {
return;
}
// Apply provider/model/region as a coherent set (same discipline as
// applyRequestRouter): only fill fields the caller did not pin.
if (decision.provider && !options.provider) {
options.provider = decision.provider;
if (decision.model && !options.model) {
options.model = decision.model;
}
if (decision.region && !options.region) {
options.region = decision.region;
}
}
else if (!decision.provider && decision.model && !options.model) {
options.model = decision.model;
}
// Narrow tools unless the caller disabled tools entirely.
if (!opt.disableTools) {
if (decision.excludeTools && decision.excludeTools.length > 0) {
opt.excludeTools = Array.from(new Set([...(opt.excludeTools ?? []), ...decision.excludeTools]));
}
if (decision.toolFilter && decision.toolFilter.length > 0) {
const allow = decision.toolFilter;
opt.toolFilter =
opt.toolFilter && opt.toolFilter.length > 0
? opt.toolFilter.filter((t) => allow.includes(t))
: [...allow];
}
}
// Context budget: only ever narrowed, and only when the caller left it
// unset. A caller that pinned a threshold has made a statement about
// their own traffic that a per-request guess should not overrule.
if (decision.compactionThreshold !== undefined &&
options.compactionThreshold === undefined) {
options.compactionThreshold = decision.compactionThreshold;
logger.debug("[NeuroLink] classifierRouter context budget", {
compactionThreshold: decision.compactionThreshold,
contextScope: decision.contextScope,
});
}
// Mark routed so orchestration/requestRouter stand down this turn.
if (decision.provider || decision.model) {
const ctx = opt.context ?? {};
ctx.__classifierRouted = true;
opt.context = ctx;
}
// Telemetry: a debug log is not an observability surface. Routing
// silently changes which model — and therefore which bill — serves a
// request, so the decision has to be visible on the trace next to the
// generation it produced.
try {
const activeSpan = trace.getActiveSpan();
if (activeSpan) {
activeSpan.setAttribute("classifier_routing.difficulty", decision.difficulty ?? "unknown");
activeSpan.setAttribute("classifier_routing.provider", options.provider ?? "");
activeSpan.setAttribute("classifier_routing.model", options.model ?? "");
if (decision.contextScope) {
activeSpan.setAttribute("classifier_routing.context_scope", decision.contextScope);
}
if (decision.compactionThreshold !== undefined) {
activeSpan.setAttribute("classifier_routing.compaction_threshold", decision.compactionThreshold);
}
if (decision.reason) {
activeSpan.setAttribute("classifier_routing.reason", decision.reason);
}
}
}
catch {
// Telemetry must never affect routing behaviour.
}
if (decision.reason) {
logger.debug("[NeuroLink] classifierRouter decision", {
reason: decision.reason,
difficulty: decision.difficulty,
provider: options.provider,
model: options.model,
});
}
}
catch (classifierErr) {
logger.warn("[NeuroLink] classifierRouter threw — proceeding unrouted", {
error: classifierErr instanceof Error
? classifierErr.message
: String(classifierErr),
});
}
}
async prepareGenerateAugmentations(options) {
if (options.rag?.files?.length) {
try {
const { prepareRAGTool } = await import("./rag/ragIntegration.js");
const ragResult = await prepareRAGTool(options.rag, options.provider);
if (!options.tools) {
options.tools = {};
}
options.tools[ragResult.toolName] =
ragResult.tool;
options.systemPrompt =
(options.systemPrompt || "") +
[
`\n\nIMPORTANT: You have a tool called "${ragResult.toolName}" that searches through`,
`${ragResult.filesLoaded} loaded document(s) containing ${ragResult.chunksIndexed} indexed chunks.`,
`ALWAYS use the "${ragResult.toolName}" tool FIRST to answer the user's question before using any other tools.`,
`This tool searches your local knowledge base of pre-loaded documents and is the primary source of truth.`,
`Do NOT use websearchGrounding or any web search tools when the answer can be found in the loaded documents.`,
].join(" ");
logger.info("[RAG] Tool injected into generate()", {
toolName: ragResult.toolName,
filesLoaded: ragResult.filesLoaded,
chunksIndexed: ragResult.chunksIndexed,
});
}
catch (error) {
logger.warn("[RAG] Failed to prepare RAG tool, continuing without RAG", {
error: error instanceof Error ? error.message : String(error),
});
}
}
// Skills: surface the discovery listing and inject the per-call
// use_skill / read_skill_resource tools (bodies load on activation).
await this.applySkillsAugmentation(options);
// Media-only modes (avatar, music, video, ppt) do not have a meaningful
// text prompt to augment with memory — skip injection to avoid corrupting
// the empty/synthesized input.text that was set for these modes.
const mediaOnlyMode = options.output?.mode === "avatar" ||
options.output?.mode === "music" ||
options.output?.mode === "video" ||
options.output?.mode === "ppt";
if (mediaOnlyMode ||
!this.shouldReadMemory(options.memory, options.context?.userId) ||
!options.context?.userId) {
return;
}
try {
if (options.input) {
options.input.text = await this.retrieveMemory(options.input.text ?? "", options.context.userId, options.memory?.additionalUsers);
}
logger.debug("Memory retrieval successful (generate)");
}
catch (error) {
logger.warn("Memory retrieval failed (generate):", error);
}
}
async buildGenerateTextOptions(options, originalPrompt, factoryResult) {
const baseOptions = {
prompt: options.input?.text,
provider: options.provider,
model: options.model,
temperature: options.temperature,
// Listed explicitly because this literal is the only road into the
// provider: `topP` is declared on both `GenerateOptions` and
// `TextGenerationOptions`, but it was missing here and so was silently
// dropped on every generate() call regardless of provider, while
// stream() (which spreads its options) already forwarded it.
topP: options.topP,
maxTokens: options.maxTokens,
systemPrompt: options.systemPrompt,
schema: options.schema,
output: options.output,
tools: options.tools,
disableTools: options.disableTools,
toolFilter: options.toolFilter,
excludeTools: options.excludeTools,
// This explicit field list is the only road into the provider, so a
// flag left out here never reaches BaseProvider — which is exactly how
// disableInternalFallback was dropped on generate() while stream()
// (which spreads its options) honoured it.
disableInternalFallback: options.disableInternalFallback,
maxSteps: options.maxSteps,
toolRoots: options.toolRoots,
toolChoice: options.toolChoice,
prepareStep: options.prepareStep,
enabledToolNames: options.enabledToolNames,
enableAnalytics: options.enableAnalytics,
enableEvaluation: options.enableEvaluation,
context: options.context,
evaluationDomain: options.evaluationDomain,
toolUsageContext: options.toolUsageContext,
input: options.input,
// Multimodal file-processing options must survive the reconstruction
// into TextGenerationOptions — the message builder reads them downstream
// (csv encoding/formatting/sanitization/parse timeout, PDF
// password/decryption #258). Omitting them here silently dropped e.g.
// every csvOptions field (incl. the CLI --csv-* flags) or
// pdfOptions.password so the CLI --pdf-password / SDK pdfOptions never
// reached convertToImages.
csvOptions: options.csvOptions,
pdfOptions: options.pdfOptions,
imageOptions: options.imageOptions,
// #413/#440: the audio option bag belongs in the same list. Left out
// here it was accepted by the public type and then silently discarded
// before the message builder, like every field above.
audioOptions: options.audioOptions,
// videoOptions belongs in that same list and was never in it, so no SDK
// caller's video settings have ever reached a provider -- frames and
// quality included, not just the native-video flags added alongside.
videoOptions: options.videoOptions,
officeOptions: options.officeOptions,
region: options.region,
tts: options.tts,
stt: options.stt,
fileRegistry: this.fileRegistry,
timeout: options.timeout,
turnTimeoutMs: options.turnTimeoutMs,
stallTimeoutMs: options.stallTimeoutMs,
wrapupTimeLeadMs: options.wrapupTimeLeadMs,
toolTimeoutMs: options.toolTimeoutMs,
abortSignal: options.abortSignal,
skipToolPromptInjection: options.skipToolPromptInjection,
middleware: options.middleware,
conversationMessages: options.conversationMessages,
credentials: options.credentials,
// Extended thinking. Every provider gates it on `thinkingConfig`, so
// omitting the field here meant it never reached one: the request went
// out with no thinking block and the result carried no reasoning, with
// nothing raised to say the option had been discarded. This is the same
// failure the note above records for `disableInternalFallback` — an
// allowlist that silently swallows a documented option.
//
// `thinkingConfig` is the only one of the documented thinking options
// that `GenerateOptions` actually declares. `thinking`, `thinkingBudget`
// and `thinkingLevel` are folded into a `thinkingConfig` only by the CLI,
// in `src/lib/utils/thinkingConfig.ts`; nothing on the SDK path does that
// merge. They exist solely on the internal `TextGenerationOptions`, so no
// caller can pass them through
// generate() today. Declaring them is a public-type decision and is
// deliberately left out of this fix.
thinkingConfig: options.thinkingConfig,
// Lifecycle callbacks must reach the provider so non-AI-SDK paths
// (Vertex's native @google/genai, native Bedrock, Ollama, etc.) can
// invoke them directly. Pipeline A also still receives them via the
// wrapped middleware config set by applyGenerateLifecycleMiddleware.
onFinish: options.onFinish,
onError: options.onError,
};
const extraContext = options;
if (extraContext.sessionId || extraContext.userId) {
baseOptions.context = {
...baseOptions.context,
...(extraContext.sessionId && !baseOptions.context?.sessionId
? { sessionId: extraContext.sessionId }
: {}),
...(extraContext.userId && !baseOptions.context?.userId
? { userId: extraContext.userId }
: {}),
};
}
const textOptions = bindFileToolRoots(enhanceTextGenerationOptions(baseOptions, factoryResult), this.fileToolRootsFor(options));
if (this.conversationMemory) {
textOptions.conversationMemoryConfig = this.conversationMemory.config;
textOptions.originalPrompt = originalPrompt;
}
const { toolResults, enhancedPrompt } = await this.detectAndExecuteTools(textOptions.prompt || options.input?.text || "", factoryResult.domainType);
if (enhancedPrompt !== textOptions.prompt) {
textOptions.prompt = enhancedPrompt;
logger.debug("Enhanced prompt with tool results", {
originalLength: (options.input?.text ?? "").length,
enhancedLength: enhancedPrompt.length,
toolResults: toolResults.length,
});
}
return textOptions;
}
/**
* Provider-agnostic JSON recovery for schema requests. Structured-output
* enforcement makes valid JSON the overwhelming case; for every other
* provider path — including generate() overrides (Vertex, Anthropic,
* Bedrock, Google AI Studio) — object/array roots are recovered here via
* balanced-scan + jsonrepair and scalar JSON roots via plain JSON.parse,
* with the parsed value exposed as `structuredData`. If nothing JSON-shaped
* is recoverable (pure prose), the raw text is returned, `structuredData`
* stays undefined, and a WARN makes the case observable.
*
* Mutates `textResult` in place, and must run BEFORE the end-of-generation
* emits so event consumers see the same content/structuredData the caller
* receives.
*/
recoverStructuredData(textResult, schema) {
// A provider path that produced its own `structuredData` normally owns it.
// The one exception is a STRING the caller's schema rejects: that is the
// raw completion leaking through as structured output (the shape a
// truncated response degrades to), so re-run recovery over the text rather
// than handing back a value the declared schema forbids.
const structuredIsRejectedString = typeof textResult.structuredData === "string" &&
!schemaAccepts(schema, textResult.structuredData);
if (!schema ||
(textResult.structuredData !== undefined &&
!structuredIsRejectedString) ||
typeof textResult.content !== "string") {
return;
}
if (structuredIsRejectedString) {
textResult.structuredData = undefined;
}
const coerced = coerceJsonToSchema(textResult.content, schema);
if (coerced) {
textResult.content = coerced.content;
textResult.structuredData = coerced.structuredData;
if (coerced.repaired) {
textResult.jsonRepaired = true;
}
if (coerced.truncated) {
textResult.jsonTruncated = true;
}
return;
}
const scalar = recoverScalarRoot(textResult.content, schema);
switch (scalar.kind) {
case "empty":
// A JSON-encoded empty string is an EMPTY completion, not a recovered
// scalar — normalize to a true empty so callers' empty-response
// handling fires instead of a literal '""' reaching the user.
// `structuredData` stays undefined.
textResult.content = "";
logger.warn("[NeuroLink] schema requested but the model returned an empty JSON string; normalizing to empty content", { provider: textResult.provider, model: textResult.model });
break;
case "accepted":
// Only publish a scalar root the caller's schema actually accepts.
// Under an OBJECT schema a recovered string is the raw completion in
// disguise — the shape a truncated response degrades to — and exposing
// it hands the caller a `structuredData` that violates the schema they
// passed.
textResult.structuredData = scalar.value;
break;
case "rejected":
logger.warn("[NeuroLink] recovered a JSON scalar the requested schema rejects; leaving structuredData unset", {
provider: textResult.provider,
model: textResult.model,
scalarType: typeof scalar.value,
});
break;
case "nullish":
// JSON null/undefined — no structured value to publish.
break;
case "not-json":
logger.warn("[NeuroLink] schema requested but no JSON could be recovered from model output; returning raw text", { provider: textResult.provider, model: textResult.model });
break;
}
}
finalizeGenerateRequestResult(params) {
const { generateSpan, options, textOptions, textResult, factoryResult, originalPrompt, startTime, } = params;
this.recoverStructuredData(textResult, textOptions.schema);
// Surface truncation when a schema was requested: either the provider
// reported finishReason="length" or the recovered JSON came from an
// unclosed span. Either way `structuredData` may be incomplete — warn at
// info level so it is observable in production (not just debug logs).
if (textOptions.schema) {
if (textResult.finishReason === "length") {
textResult.jsonTruncated = true;
}
if (textResult.jsonTruncated) {
logger.warn("[NeuroLink] Structured output may be truncated (finishReason=length or unclosed JSON); " +
"increase maxTokens to fit the full response.", {
provider: textResult.provider,
model: textResult.model,
finishReason: textResult.finishReason,
outputTokens: textResult.usage?.output,
});
}
}
// Skip the top-level `generation:end` emission when the provider already
// emitted it from its native generate path (Vertex / Google AI Studio).
// Without this guard, native-path providers would surface TWO events
// per generate call (Pipeline A path: 1 from provider native + 1 here;
// standard AI-SDK path: 1 here). The observability suite asserts on
// emission count and would (and did) fail.
const nativeAlreadyEmitted = !!textResult._generationEndEmitted;
if (!nativeAlreadyEmitted) {
this.emitter.emit("generation:end", {
provider: textResult.provider,
responseTime: Date.now() - startTime,
toolsUsed: textResult.toolsUsed,
timestamp: Date.now(),
result: textResult,
// Use the effective prompt (which already incorporates STT-transcribed
// text for audio-only calls) so observers see the real prompt instead
// of an empty string. Falls back through the same chain as before for
// text-only calls.
prompt: originalPrompt ||
options.input?.text ||
options.prompt,
temperature: textOptions.temperature,
maxTokens: textOptions.maxTokens,
// A2 fix: Signal that Pipeline A (AI SDK → @langfuse/otel) already
// creates a GENERATION observation for this call. The generation:end
// listener should skip creating a duplicate Pipeline B span.
pipelineAHandled: true,
});
}
this.emitter.emit("response:end", textResult.content || "");
this.emitter.emit("message", `Generation completed in ${Date.now() - startTime}ms`);
const generateResult = {
content: textResult.content,
structuredData: textResult.structuredData,
finishReason: textResult.finishReason,
// Turn-exit discriminator + raw provider stop reason + step count from
// native agentic loops — this DTO is an explicit field list, so new
// provider-result fields MUST be copied here or they silently vanish.
stopReason: textResult.stopReason,
rawFinishReason: textResult.rawFinishReason,
stepsUsed: textResult.stepsUsed,
jsonRepaired: textResult.jsonRepaired,
jsonTruncated: textResult.jsonTruncated,
provider: textResult.provider,
model: textResult.model,
usage: textResult.usage
? {
input: textResult.usage.input || 0,
output: textResult.usage.output || 0,
total: textResult.usage.total || 0,
// Optional tiers must be forwarded or they silently vanish at
// this DTO boundary (cache-aware cost + savings need them).
...(textResult.usage.cacheReadTokens !== undefined && {
cacheReadTokens: textResult.usage.cacheReadTokens,
}),
...(textResult.usage.cacheCreationTokens !== undefined && {
cacheCreationTokens: textResult.usage.cacheCreationTokens,
}),
...(textResult.usage.reasoning !== undefined && {
reasoning: textResult.usage.reasoning,
}),
...(textResult.usage.cacheSavingsPercent !== undefined && {
cacheSavingsPercent: textResult.usage.cacheSavingsPercent,
}),
}
: undefined,
responseTime: textResult.responseTime,
toolsUsed: textResult.toolsUsed,
toolCalls: textResult.toolCalls ?? [],
toolExecutions: toToolExecutionRecords(textResult.toolExecutions),
enhancedWithTools: textResult.enhancedWithTools,
availableTools: transformAvailableTools(textResult.availableTools),
analytics: textResult.analytics,
imageOutput: textResult.imageOutput,
evaluation: textResult.evaluation
? {
...textResult.evaluation,
isOffTopic: textResult.evaluation.isOffTopic ?? false,
alertSeverity: textResult.evaluation.alertSeverity ?? "none",
reasoning: textResult.evaluation.reasoning ?? "No evaluation provided",
evaluationModel: textResult.evaluation.evaluationModel ?? "unknown",
evaluationTime: textResult.evaluation.evaluationTime ?? Date.now(),
evaluationDomain: textResult.evaluation.evaluationDomain ??
textOptions.evaluationDomain ??
factoryResult.domainType,
}
: undefined,
audio: textResult.audio,
ttsMetadata: textResult.ttsMetadata,
transcription: textResult.transcription,
video: textResult.video,
avatar: textResult.avatar,
music: textResult.music,
ppt: textResult.ppt,
// Forward reasoning/reasoningTokens from the provider layer.
// The shared native generate loop extracts these from vendor reasoning
// parts (DeepSeek's `reasoning_content`, Anthropic thinking blocks,
// OpenAI o-series) for the providers that use it — OpenAI-compatible,
// Anthropic and SageMaker. Vertex and AI Studio override generate() and
// leave the text field undefined. They're declared on
// `GenerateResult`, but the builder previously dropped them on the
// floor — so callers asking for `result.reasoning` got `undefined`
// even when the model emitted a chain-of-thought.
reasoning: textResult.reasoning,
reasoningTokens: textResult.reasoningTokens,
...(textResult.retries && { retries: textResult.retries }),
};
// Response validation (if configured)
if (options.responseValidation) {
const validationResult = validateResponse(generateResult.content ?? "", {
minLength: options.responseValidation.minLength,
maxLength: options.responseValidation.maxLength,
requiredPhrases: options.responseValidation.requiredPhrases,
forbiddenPhrases: options.responseValidation.forbiddenPhrases,
jsonSchema: options.responseValidation.jsonSchema,
customValidator: options.responseValidation.customValidator,
truncationAction: options.responseValidation.truncationAction,
truncationSuffix: options.responseValidation.truncationSuffix,
retryOnFailure: options.responseValidation.retryOnFailure,
maxRetries: options.responseValidation.maxRetries,
});
if (validationResult.action === "abort") {
throw new Error(validationResult.feedback ??
`Response validation failed: ${validationResult.issues.map((i) => i.message).join("; ")}`);
}
// Apply validated/truncated text
if (validationResult.text !== generateResult.content) {
generateResult.content = validationResult.text;
}
}
if (generateResult.analytics?.cost && generateResult.analytics.cost > 0) {
this._sessionCostUsd += generateResult.analytics.cost;
}
this.scheduleGenerateMemoryStorage(options, originalPrompt, generateResult);
generateSpan.setAttribute("neurolink.output_length", generateResult.content?.length || 0);
generateSpan.setAttribute("neurolink.tokens.input", generateResult.usage?.input || 0);
generateSpan.setAttribute("neurolink.tokens.output", generateResult.usage?.output || 0);
generateSpan.setAttribute("neurolink.finish_reason", generateResult.finishReason || "unknown");
// G3 fix: Record step count and whether max steps was reached
// Read steps from the raw provider result (textResult), not the flattened DTO
const stepCount = textResult?.steps?.length ?? 1;
const maxSteps = options.maxSteps ?? 200; // DEFAULT_MAX_STEPS
generateSpan.setAttribute("neurolink.step_count", stepCount);
generateSpan.setAttribute("neurolink.max_steps_reached", stepCount >= maxSteps);
generateSpan.setAttribute("neurolink.result_provider", generateResult.provider || "unknown");
generateSpan.setAttribute("neurolink.result_model", generateResult.model || "unknown");
generateSpan.setAttribute("generate.retry_count", generateResult.retries?.count || 0);
generateSpan.setStatus({ code: SpanStatusCode.OK });
return generateResult;
}
emitGenerateErrorEvent(optionsOrPrompt, error) {
const errProvider = typeof optionsOrPrompt === "object"
? optionsOrPrompt.provider || "unknown"
: "unknown";
const errModel = typeof optionsOrPrompt === "object"
? optionsOrPrompt.model || "unknown"
: "unknown";
// Distinguish client aborts from real failures so consumers (and Langfuse)
// can route them differently. `aborted: true` is additive — `success`
// remains false for backwards-compat with existing listeners that only
// branch on the boolean.
const aborted = isAbortError(error);
// A native path that already emitted its own `generation:end` for this
// failure marks the error, because on the failure path it has no result
// object to mark. Re-emitting here would double every native failure.
if (error &&
typeof error === "object" &&
error._generationEndEmitted) {
return;
}
try {
this.emitter.emit("generation:end", {
provider: errProvider,
model: errModel,
responseTime: 0,
error: error instanceof Error ? error.message : String(error),
success: false,
aborted,
});
}
catch (emitError) {
void emitError;
}
}
/**
* Schedule non-blocking memory storage after generate completes.
*/
scheduleGenerateMemoryStorage(options, originalPrompt, generateResult) {
// Memory storage
if (this.shouldWriteMemory(options.memory, options.context?.userId, generateResult.content) &&
options.context?.userId) {
this.storeMemoryInBackground(originalPrompt ?? "", generateResult.content.trim(), options.context.userId, options.memory?.additionalUsers, options.context);
}
}
/**
* Handle PPT generation mode
*/
async generateWithPPT(options) {
const startTime = Date.now();
// Dynamic import to avoid circular deps (same pattern as RAG)
const { generatePresentation } = await import("./features/ppt/presentationOrchestrator.js");
const { extractPPTContext, getEffectivePPTProvider } = await import("./features/ppt/utils.js");
// Get provider instance for content planning
const requestedProvider = (options.provider || "vertex");
const provider = await AIProviderFactory.createProvider(requestedProvider, options.model, true, this, undefined, this.resolveCredentials(options.credentials));
// Resolve effective PPT provider (may auto-select if current is not PPT-compatible)
const effectiveProvider = await getEffectivePPTProvider(provider, requestedProvider, options.model || "default", this);
// Extract PPT context from options
const pptContext = extractPPTContext(options);
// Generate the presentation
const pptResult = await generatePresentation({
context: pptContext,
provider: effectiveProvider.provider,
providerName: effectiveProvider.providerName,
modelName: effectiveProvider.modelName,
neurolink: this,
});
// Map PPT result to GenerateResult
return {
content: `Presentation generated: ${pptResult.filePath} (${pptResult.totalSlides} slides)`,
finishReason: "stop",
provider: pptResult.provider || requestedProvider,
model: pptResult.model || options.model || "default",
usage: undefined,
responseTime: Date.now() - startTime,
ppt: pptResult,
};
}
/**
* Dispatch a music-generation request to the registered music handler
* for the provider named in `options.output.music.provider`.
*/
async generateWithMusic(options, generateSpan) {
const startTime = Date.now();
const musicOptions = options.output?.music;
if (!musicOptions) {
throw new Error('output.mode="music" requires output.music with at least { provider, prompt }.');
}
const providerName = musicOptions.provider;
if (!providerName) {
throw new Error('output.music.provider is required (e.g. "beatoven", "elevenlabs-music", "lyria", "replicate").');
}
// This early-dispatch path never creates an AI provider, so
// ProviderRegistry.registerAllProviders() has NOT necessarily run —
// with the module-scope auto-registration retired, the handlers must
// be registered here explicitly. registerDefaultMusicHandlers() is
// idempotent (skip-if-registered), so the repeat call is free.
const { registerDefaultMusicHandlers } = await import("./music/index.js");
registerDefaultMusicHandlers();
const { MusicProcessor } = await import("./utils/musicProcessor.js");
const musicResult = await MusicProcessor.generate(providerName, {
...musicOptions,
prompt: musicOptions.prompt ?? options.input?.text ?? "",
});
generateSpan.setAttribute("neurolink.music.provider", providerName);
generateSpan.setAttribute("neurolink.music.bytes", musicResult.size);
generateSpan.setStatus({ code: SpanStatusCode.OK });
return {
content: `Music generated (${providerName}, ${musicResult.size} bytes, ${musicResult.format}).`,
finishReason: "stop",
provider: providerName,
model: musicResult.metadata?.model ?? providerName,
usage: undefined,
responseTime: Date.now() - startTime,
music: musicResult,
};
}
/**
* Dispatch an avatar (lip-sync) request to the registered avatar handler
* for the provider named in `options.output.avatar.provider`.
*/
async generateWithAvatar(options, generateSpan) {
const startTime = Date.now();
const avatarOptions = options.output?.avatar;
if (!avatarOptions) {
throw new Error('output.mode="avatar" requires output.avatar with at least { provider, image, audio|text }.');
}
const providerName = avatarOptions.provider;
if (!providerName) {
throw new Error('output.avatar.provider is required (e.g. "d-id", "heygen", "replicate").');
}
// Same early-dispatch registration as generateWithMusic above: no AI
// provider is created on this path, so the retired module-scope
// auto-run must be replaced by an explicit (idempotent) call here.
const { registerDefaultAvatarHandlers } = await import("./avatar/index.js");
registerDefaultAvatarHandlers();
const { AvatarProcessor } = await import("./utils/avatarProcessor.js");
const avatarResult = await AvatarProcessor.generate(providerName, avatarOptions);
generateSpan.setAttribute("neurolink.avatar.provider", providerName);
generateSpan.setAttribute("neurolink.avatar.bytes", avatarResult.size);
generateSpan.setStatus({ code: SpanStatusCode.OK });
return {
content: `Avatar generated (${providerName}, ${avatarResult.size} bytes, ${avatarResult.format}).`,
finishReason: "stop",
provider: providerName,
model: avatarResult.metadata?.model ?? providerName,
usage: undefined,
responseTime: Date.now() - startTime,
avatar: avatarResult,
};
}
/**
* Generate with workflow engine integration
* Returns both original and processed responses for AB testing
*/
async generateWithWorkflow(options) {
const workflowStartTime = Date.now();
logger.debug("[NeuroLink] Executing workflow generation", {
workflowId: options.workflow,
hasInlineConfig: !!options.workflowConfig,
prompt: (options.input?.text ?? "").substring(0, 100),
startTime: workflowStartTime,
});
// Determine workflow configuration
let workflowConfig;
if (options.workflowConfig) {
// Use inline config
workflowConfig = options.workflowConfig;
}
else if (options.workflow) {
// Look up predefined workflow
workflowConfig = getWorkflow(options.workflow);
if (!workflowConfig) {
throw new Error(`Workflow '${options.workflow}' not found in registry`);
}
}
else {
throw new Error("Either workflow or workflowConfig must be provided");
}
// Execute workflow
const workflowResult = await runWorkflow(workflowConfig, {
prompt: options.input?.text ?? "",
conversationHistory: options.conversationMessages
?.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
role: m.role,
content: stringifyContentSafe(m.content),
})) ??
options.conversationHistory,
timeout: options.timeout,
verbose: false,
metadata: options.context,
});
// Build GenerateResult with workflow data
const generateResult = {
// Primary output (backward compatible) - use the original best response
content: workflowResult.content,
// Provider info from selected response
provider: workflowResult.selectedResponse?.provider ||
workflowConfig.models[0]?.provider,
model: workflowResult.selectedResponse?.model ||
workflowConfig.models[0]?.model,
// Basic usage info
usage: workflowResult.usage
? {
input: workflowResult.usage.totalInputTokens,
output: workflowResult.usage.totalOutputTokens,
total: workflowResult.usage.totalTokens,
}
: undefined,
// Performance
responseTime: workflowResult.totalTime,
// Workflow-specific data
workflow: {
originalResponse: workflowResult.originalContent || workflowResult.content, // Original unmodified best response
processedResponse: workflowResult.content, // After conditioning (with metadata)
ensembleResponses: workflowResult.ensembleResponses.map((r) => ({
provider: r.provider,
model: r.model,
content: r.content,
responseTime: r.responseTime,
status: r.status,
error: r.error,
})),
judgeScores: workflowResult.judgeScores
? {
scores: workflowResult.judgeScores.scores,
reasoning: workflowResult.reasoning,
selectedModel: `${workflowResult.selectedResponse?.provider}-${workflowResult.selectedResponse?.model}`,
}
: undefined,
selectedModel: `${workflowResult.selectedResponse?.provider}-${workflowResult.selectedResponse?.model}`,
metrics: {
totalTime: workflowResult.totalTime,
ensembleTime: workflowResult.ensembleTime,
judgeTime: workflowResult.judgeTime,
conditioningTime: workflowResult.conditioningTime,
},
workflowId: workflowResult.workflow,
workflowName: workflowResult.workflowName,
},
};
logger.debug("[NeuroLink] Workflow generation complete", {
workflowId: workflowResult.workflow,
selectedModel: generateResult.workflow?.selectedModel,
score: workflowResult.score,
totalTime: workflowResult.totalTime,
});
return generateResult;
}
/**
* Stream with workflow engine integration
* Progressive streaming: yields preliminary response (first model) then final synthesis
*/
async streamWithWorkflow(options, startTime) {
logger.debug("[NeuroLink] Executing workflow streaming (progressive)", {
workflowId: options.workflow,
hasInlineConfig: !!options.workflowConfig,
prompt: (options.input?.text ?? "").substring(0, 100),
});
// Determine workflow configuration
let workflowConfig;
if (options.workflowConfig) {
workflowConfig = options.workflowConfig;
}
else if (options.workflow) {
workflowConfig = getWorkflow(options.workflow);
if (!workflowConfig) {
throw new Error(`Workflow '${options.workflow}' not found in registry`);
}
}
else {
throw new Error("Either workflow or workflowConfig must be provided");
}
// Import streaming workflow runner
const { runWorkflowWithStreaming } = await import("./workflow/core/workflowRunner.js");
// Execute workflow with progressive streaming
const workflowStream = runWorkflowWithStreaming(workflowConfig, {
prompt: options.input?.text ?? "",
conversationHistory: options.conversationMessages
?.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
role: m.role,
content: stringifyContentSafe(m.content),
})) ??
options.conversationHistory,
timeout: options.timeout,
verbose: false,
metadata: options.context,
streaming: true,
});
// Store final result for metadata
let finalResult = null;
let preliminaryTime = 0;
// Create a generator that yields progressive chunks
const stream = (async function* () {
for await (const chunk of workflowStream) {
if (chunk.type === "preliminary") {
preliminaryTime = Date.now() - startTime;
logger.debug("[NeuroLink] Streaming preliminary response", {
responseTime: preliminaryTime,
contentLength: chunk.content.length,
});
yield {
content: chunk.content,
type: "preliminary",
};
}
else if (chunk.type === "final") {
finalResult = chunk.partialResult ?? null;
const finalTime = Date.now() - startTime;
logger.debug("[NeuroLink] Streaming final synthesis", {
responseTime: finalTime,
contentLength: chunk.content.length,
});
yield {
content: chunk.content,
type: "final",
};
}
}
})();
const streamResult = {
stream,
// Provider info (will be from final result)
provider: workflowConfig.models[0]?.provider,
model: workflowConfig.models[0]?.model,
// Metadata
metadata: {
streamId: `workflow-${workflowConfig.id}-${Date.now()}`,
startTime,
responseTime: 0, // Will be updated after stream completes
},
// Note: Workflow data will be populated after stream completes
// For now, return placeholder that will be updated via stream metadata
};
// Wrap stream to capture final result and populate metadata
const originalStream = streamResult.stream;
streamResult.stream = (async function* () {
for await (const chunk of originalStream) {
yield chunk;
}
// After stream completes, update result with final workflow data
if (finalResult) {
const result = finalResult;
const responseTime = Date.now() - startTime;
// Update usage if available
if (result.usage) {
streamResult.usage = {
input: result.usage.totalInputTokens,
output: result.usage.totalOutputTokens,
total: result.usage.totalTokens,
};
}
// Update metadata
streamResult.metadata = {
...streamResult.metadata,
totalChunks: 2, // Preliminary + final
responseTime,
preliminaryTime,
};
// Build workflow data with proper type safety
const ensembleResponses = result.ensembleResponses?.map((r) => ({
provider: r.provider,
model: r.model,
content: r.content,
responseTime: r.responseTime,
status: r.status,
error: r.error,
})) ?? [];
const judgeScores = result.judgeScores
? {
scores: result.judgeScores.scores,
reasoning: result.reasoning ?? "",
selectedModel: result.selectedResponse
? `${result.selectedResponse.provider}-${result.selectedResponse.model}`
: "unknown",
}
: undefined;
streamResult.workflow = {
originalResponse: result.originalContent ?? result.content ?? "",
processedResponse: result.content ?? "",
ensembleResponses,
judgeScores,
selectedModel: result.selectedResponse
? `${result.selectedResponse.provider}-${result.selectedResponse.model}`
: "unknown",
metrics: {
totalTime: result.totalTime ?? responseTime,
ensembleTime: result.ensembleTime ?? 0,
judgeTime: result.judgeTime,
conditioningTime: result.conditioningTime,
},
workflowId: result.workflow ?? workflowConfig.id,
workflowName: result.workflowName ?? workflowConfig.name,
};
}
})();
logger.debug("[NeuroLink] Workflow streaming initialized", {
workflowId: workflowConfig.id,
});
return streamResult;
}
/**
* BACKWARD COMPATIBILITY: Legacy generateText method
* Internally calls generate() and converts result format
*/
async generateText(options) {
return logger.runInInstanceScope(this.logInstanceId, () => this.generateTextInInstanceScope(options));
}
/**
* Body of {@link generateText}, always entered through the instance log scope.
*/
async generateTextInInstanceScope(options) {
// Validate required parameters for backward compatibility
if (!options.prompt ||
typeof options.prompt !== "string" ||
options.prompt.trim() === "") {
throw new Error("GenerateText options must include prompt as a non-empty string");
}
// Isolate from the caller's object, then resolve file-tool roots exactly
// as generate() does: a widening or missing root rejects here, before any
// model call, and the resolved policy travels on the private channel.
const isolated = cloneOptionsForCallIsolation(options);
// NL-004: Resolve model aliases/deprecations before processing
isolated.model = resolveModel(isolated.model, this.modelAliasConfig);
bindFileToolRoots(isolated, this.resolveFileToolRoots(isolated.toolRoots));
// Use internal generation method directly
return await this.generateTextInternal(isolated);
}
/**
* REDESIGNED INTERNAL GENERATION - NO CIRCULAR DEPENDENCIES
*
* This method implements a clean fallback chain:
* 1. Initialize conversation memory if enabled
* 2. Inject conversation history into prompt
* 3. Try MCP-enhanced generation if available
* 4. Fall back to direct provider generation
* 5. Store conversation turn for future context
*/
async generateTextInternal(options) {
return tracers.sdk.startActiveSpan("neurolink.generateTextInternal", { kind: SpanKind.INTERNAL }, (internalSpan) => this.executeGenerateTextInternalWithSpan(options, internalSpan));
}
async executeGenerateTextInternalWithSpan(options, internalSpan) {
try {
const context = this.initializeGenerateTextInternalContext(options);
internalSpan.setAttribute("neurolink.request_id", context.requestId);
internalSpan.setAttribute("neurolink.has_conversation_memory", !!this.conversationMemory);
internalSpan.setAttribute("neurolink.provider", options.provider || "auto");
internalSpan.setAttribute("neurolink.model", options.model || "default");
this.logGenerateTextInternalStart(context.generateInternalId, context.generateInternalStartTime, context.generateInternalHrTimeStart, options, context.functionTag);
this.emitGenerationStartEvents(options);
return await this.runGenerateTextInternalFlow(options, internalSpan, context);
}
catch (error) {
// Client aborts are user-initiated cancellations, not system faults.
// Setting status=ERROR forces Langfuse to level=ERROR (see
// ContextEnricher.onEnd → instrumentation.ts:691). Instead leave status
// unset and stamp ai.finishReason=aborted so applyNonErrorLangfuseLevel
// maps it to level=WARNING with the canonical "Generation aborted by
// client" status_message. Matches Curator telemetry-gaps Issue 5a.
if (isAbortError(error)) {
internalSpan.setAttribute("ai.finishReason", "aborted");
internalSpan.setAttribute("neurolink.aborted", true);
}
else {
internalSpan.recordException(error instanceof Error ? error : new Error(String(error)));
internalSpan.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
finally {
internalSpan.end();
}
}
initializeGenerateTextInternalContext(options) {
const generateInternalId = `generate-internal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const existingRequestId = options.context?.requestId;
const requestId = typeof existingRequestId === "string" && existingRequestId
? existingRequestId
: `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
options.context = { ...options.context, requestId };
return {
generateInternalId,
generateInternalStartTime: Date.now(),
generateInternalHrTimeStart: process.hrtime.bigint(),
functionTag: "NeuroLink.generateTextInternal",
requestId,
};
}
async runGenerateTextInternalFlow(options, internalSpan, context) {
try {
await this.initializeConversationMemoryForGeneration(context.generateInternalId, context.generateInternalStartTime, context.generateInternalHrTimeStart);
const mcpResult = await this.attemptMCPGeneration(options, context.generateInternalId, context.generateInternalStartTime, context.generateInternalHrTimeStart, context.functionTag);
if (mcpResult) {
return this.finalizeGenerateTextInternalResult({
path: "mcp",
result: mcpResult,
options,
internalSpan,
requestId: context.requestId,
startTime: context.generateInternalStartTime,
});
}
if (options.abortSignal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
await this.captureOriginalConversationMessagesForRecovery(options);
const directResult = await this.directProviderGeneration(options);
logger.debug(`[${context.functionTag}] Direct generation successful`);
return this.finalizeGenerateTextInternalResult({
path: "direct",
result: directResult,
options,
internalSpan,
requestId: context.requestId,
startTime: context.generateInternalStartTime,
});
}
catch (error) {
const recoveredResult = await this.handleGenerateTextInternalFailure(options, context, error);
if (recoveredResult) {
return recoveredResult;
}
// Convert raw DOMException AbortErrors (and other untyped abort shapes)
// into NeuroLinkError(ABORT) so callers can branch on
// `error.category === ErrorCategory.ABORT` instead of message matching.
// Skipped if the error is already a typed abort to avoid double-wrap.
if (isAbortError(error) && !(error instanceof NeuroLinkError)) {
throw ErrorFactory.aborted(error instanceof Error ? error : new Error(String(error)));
}
throw error;
}
}
async captureOriginalConversationMessagesForRecovery(options) {
if (!this.conversationMemory) {
return;
}
const originalMessages = await getConversationMessages(this.conversationMemory, options);
options._originalConversationMessages = originalMessages
? [...originalMessages]
: undefined;
}
async finalizeGenerateTextInternalResult(params) {
const { path, result, options, internalSpan, requestId, startTime } = params;
logger.info(`[NeuroLink.generateTextInternal] generate() - COMPLETE SUCCESS${path === "mcp" ? " (MCP path)" : ""}`, {
provider: result.provider,
model: result.model,
responseTimeMs: Date.now() - startTime,
tokensUsed: result.usage?.total || 0,
toolsUsed: result.toolsUsed?.length || 0,
...(result.usage?.cacheCreationTokens !== undefined && {
cacheCreationTokens: result.usage.cacheCreationTokens,
}),
...(result.usage?.cacheReadTokens !== undefined && {
cacheReadTokens: result.usage.cacheReadTokens,
}),
...(result.usage?.cacheSavingsPercent !== undefined && {
cacheSavingsPercent: result.usage.cacheSavingsPercent,
}),
});
const memStoreStart = Date.now();
try {
await storeConversationTurn(this.conversationMemory, options, result, new Date(startTime), requestId, this.drainPendingSkillMessages(options.context?.sessionId));
this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": path }, Date.now() - memStoreStart, SpanStatus.OK);
}
catch (memoryError) {
this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": path }, Date.now() - memStoreStart, SpanStatus.ERROR, memoryError instanceof Error
? memoryError.message
: String(memoryError));
}
this.emitter.emit("response:end", result.content || "");
if (path === "direct") {
this.emitter.emit("message", "Text generation completed successfully");
}
internalSpan.setAttribute("neurolink.path", path);
internalSpan.setAttribute("neurolink.tokens.input", result.usage?.input || 0);
internalSpan.setAttribute("neurolink.tokens.output", result.usage?.output || 0);
internalSpan.setAttribute("neurolink.result_provider", result.provider || "unknown");
internalSpan.setStatus({ code: SpanStatusCode.OK });
return result;
}
async handleGenerateTextInternalFailure(options, context, error) {
const recoveredResult = await this.tryRecoverGenerateTextOverflow(options, context.functionTag, error, context.generateInternalStartTime);
if (recoveredResult) {
return recoveredResult;
}
if (isAbortError(error)) {
// Aborted generations DO NOT write to conversation memory.
// Fabricating an assistant turn out of an error condition (the previous
// "[generation was interrupted]" sentinel) pollutes the next prompt and
// — at the right shape — causes the model to echo the sentinel as its
// response. See Curator SI-069 / SI-071. Aborts are signalled to
// callers via the thrown error and the "error" emitter event below;
// there is nothing to persist, so persisting nothing is correct.
//
// Title generation continues to work: it reads the user message of the
// first *successful* turn (RedisConversationMemoryManager
// .generateConversationTitle) and never required a fabricated assistant
// turn — the previous comment claiming otherwise was inaccurate.
logger.info(`[${context.functionTag}] Generation aborted — skipping memory write (aborts must not pollute conversation history)`, {
hasMemory: !!this.conversationMemory,
memoryType: this.conversationMemory?.constructor?.name || "NONE",
sessionId: options.context?.sessionId ||
"unknown",
});
}
else {
logger.error(`[${context.functionTag}] All generation methods failed`, {
error: error instanceof Error ? error.message : String(error),
});
}
this.emitter.emit("response:end", "");
// Node EventEmitter rethrows the original error from emit("error", e) if
// there is no listener registered, which would short-circuit the caller's
// catch block and prevent the abort-typed-error wrap from running. Only
// emit when a consumer is listening; non-listening callers receive the
// error via the thrown rejection instead, which is the canonical path.
if (this.emitter.listenerCount("error") > 0) {
this.emitter.emit("error", error instanceof Error ? error : new Error(String(error)));
}
return null;
}
async tryRecoverGenerateTextOverflow(options, functionTag, error, attemptStartTimeMs) {
// Reviewer Finding #3: drop the `!this.conversationMemory` gate so
// inline-conversationMessages callers also benefit from post-provider
// recovery when their pre-dispatch estimate happens to undershoot
// and the provider rejects at a higher real token count.
if (!isContextOverflowError(error)) {
return null;
}
const inlineMessages = options._originalConversationMessages;
const callerMessages = options.conversationMessages;
if (!this.conversationMemory && !inlineMessages && !callerMessages) {
return null;
}
logger.warn(`[${functionTag}] Context overflow detected by provider, attempting smart recovery`, {
error: error instanceof Error ? error.message : String(error),
overflowProvider: getContextOverflowProvider(error),
});
try {
const actualOverflow = parseProviderOverflowDetails(error);
const originalMessages = inlineMessages ??
callerMessages ??
(this.conversationMemory
? await getConversationMessages(this.conversationMemory, options)
: []);
const recoveryBudget = checkContextBudget({
provider: options.provider || "openai",
model: options.model,
maxTokens: options.maxTokens,
currentPrompt: options.prompt,
systemPrompt: options.systemPrompt,
});
const actualTokens = actualOverflow?.actualTokens ?? recoveryBudget.estimatedInputTokens;
const budgetTokens = actualOverflow?.budgetTokens ?? recoveryBudget.availableInputTokens;
// Target the HISTORY's share, not the whole budget: the compactor's stage
// gates measure messages only, so handing them the full figure lets an
// over-budget request through untouched. The 0.7 factor stays on top as
// recovery headroom — this path runs only after the provider has already
// rejected the request once, so aiming well under is deliberate.
const recoveryOverhead = (recoveryBudget.breakdown?.systemPrompt ?? 0) +
(recoveryBudget.breakdown?.currentPrompt ?? 0);
const compactionTarget = Math.max(0, Math.floor((budgetTokens - recoveryOverhead) * 0.7));
const requiredReduction = actualTokens > 0
? (actualTokens - compactionTarget) / actualTokens
: 0.5;
// Reviewer Finding #3: escalating truncation across attempts. The
// first attempt uses the budget-derived fraction (single-round
// compaction). If that still leaves the conversation over budget,
// subsequent attempts apply progressively harder truncation
// (0.5 → 0.75 → 0.9) before giving up. This replaces the previous
// single-pass behaviour where one undersized fraction guaranteed
// failure on the next provider call.
const escalationFractions = [
Math.min(0.9, requiredReduction + 0.15),
0.5,
0.75,
0.9,
];
let lastCompactionResult = null;
let compactedMessages = originalMessages;
let verifiedBudget = null;
let recoveredFraction = -1;
for (let i = 0; i < escalationFractions.length; i++) {
const fraction = escalationFractions[i];
const compactor = new ContextCompactor({
enableSummarize: false,
enablePrune: true,
enableDeduplicate: true,
enableTruncate: true,
truncationFraction: fraction,
}, this.contextCompactorDeps());
const compactionResult = await compactor.compact(originalMessages, compactionTarget, undefined, options.context?.requestId);
if (!compactionResult.compacted) {
continue;
}
lastCompactionResult = compactionResult;
const repairedResult = repairToolPairs(compactionResult.messages);
const verifyBudget = checkContextBudget({
provider: options.provider || "openai",
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: options.systemPrompt,
currentPrompt: options.prompt,
conversationMessages: repairedResult.messages,
});
if (verifyBudget.withinBudget) {
compactedMessages = repairedResult.messages;
verifiedBudget = verifyBudget;
recoveredFraction = fraction;
break;
}
verifiedBudget = verifyBudget;
}
if (!lastCompactionResult) {
// Reviewer follow-up: when no escalation fraction managed to
// compact the conversation, the request will hit the same
// provider 400 again on retry. Surface a typed
// ContextBudgetExceededError + `compaction.insufficient` event
// instead of returning null (which lets callers propagate the
// opaque provider error).
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: [],
finalTokens: actualTokens,
budget: budgetTokens,
provider: options.provider || "openai",
model: options.model,
phase: "post-provider-recovery-no-compaction",
fractionsTried: escalationFractions,
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
throw new ContextBudgetExceededError(`Context overflow recovery: no compaction stage was able to ` +
`reduce conversation messages. Provider rejected at ` +
`~${actualTokens} tokens; budget is ${budgetTokens} tokens.`, {
estimatedTokens: actualTokens,
availableTokens: budgetTokens,
stagesUsed: [],
breakdown: {},
});
}
if (!verifiedBudget?.withinBudget) {
logger.error(`[${functionTag}] Recovery compaction insufficient after escalation, aborting retry`, {
estimatedTokens: verifiedBudget?.estimatedInputTokens,
availableTokens: verifiedBudget?.availableInputTokens,
stagesAttempted: lastCompactionResult.stagesUsed,
fractionsTried: escalationFractions,
});
// Reviewer Finding #3: emit `compaction.insufficient` so
// cost / audit listeners record the specific failure mode.
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: lastCompactionResult.stagesUsed,
finalTokens: verifiedBudget?.estimatedInputTokens,
budget: verifiedBudget?.availableInputTokens,
provider: options.provider || "openai",
model: options.model,
phase: "post-provider-recovery",
fractionsTried: escalationFractions,
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
throw new ContextBudgetExceededError(`Context overflow recovery failed. Provider rejected at ~${actualTokens} tokens, ` +
`recovery compaction achieved ${lastCompactionResult.tokensAfter} tokens ` +
`but budget is ${budgetTokens} tokens (after escalation through ` +
`${escalationFractions.length} fractions).`, {
estimatedTokens: lastCompactionResult.tokensAfter,
availableTokens: budgetTokens,
stagesUsed: lastCompactionResult.stagesUsed,
breakdown: verifiedBudget?.breakdown ?? {},
});
}
// Whole-turn budget semantics: the retry inherits the REMAINING
// turnTimeoutMs, not a fresh clock — attempt 1 already spent part of
// the caller's budget, and a fresh clock let one generate() run ~2×
// the configured deadline (the 66-minute-turn incident shape).
// Floored so a compacted retry still gets a workable window — but the
// floor never exceeds the caller's own turnTimeoutMs (a 10s caller
// budget must not receive a 30s retry).
let retryTurnTimeoutMs = options.turnTimeoutMs;
if (typeof options.turnTimeoutMs === "number" &&
Number.isFinite(options.turnTimeoutMs) &&
attemptStartTimeMs !== undefined) {
const elapsedMs = Date.now() - attemptStartTimeMs;
retryTurnTimeoutMs = Math.max(options.turnTimeoutMs - elapsedMs, Math.min(MIN_RECOVERY_TURN_BUDGET_MS, options.turnTimeoutMs));
}
logger.info(`[${functionTag}] Smart recovery verified, retrying generation`, {
tokensSaved: lastCompactionResult.tokensSaved,
compactionTarget,
verifiedTokens: verifiedBudget.estimatedInputTokens,
verifiedBudget: verifiedBudget.availableInputTokens,
recoveredFraction,
retryTurnTimeoutMs,
});
return this.directProviderGeneration({
...options,
conversationMessages: compactedMessages,
...(retryTurnTimeoutMs !== undefined && {
turnTimeoutMs: retryTurnTimeoutMs,
}),
});
}
catch (retryError) {
if (retryError instanceof ContextBudgetExceededError) {
throw retryError;
}
logger.error(`[${functionTag}] Recovery attempt failed`, {
error: retryError instanceof Error ? retryError.message : String(retryError),
});
return null;
}
}
/**
* Log generateTextInternal start with comprehensive analysis
*/
logGenerateTextInternalStart(generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, options, functionTag) {
logger.debug(`[${functionTag}] Starting generation`, {
provider: options.provider || "auto",
promptLength: options.prompt?.length || 0,
hasConversationMemory: !!this.conversationMemory,
});
}
/**
* Emit generation start events
*/
emitGenerationStartEvents(options) {
this.emitter.emit("response:start");
this.emitter.emit("message", `Starting ${options.provider || "auto"} text generation (internal)...`);
}
/**
* Initialize conversation memory for generation
* Lazily initializes memory if needed from constructor flags
*/
async initializeConversationMemoryForGeneration(generateInternalId, generateInternalStartTime, generateInternalHrTimeStart) {
const conversationMemoryStartTime = process.hrtime.bigint();
// Handle lazy initialization if needed
if (this.conversationMemoryNeedsInit && this.conversationMemoryConfig) {
await this.lazyInitializeConversationMemory(generateInternalId, generateInternalStartTime, generateInternalHrTimeStart);
}
// Normal initialization for already created memory manager
if (this.conversationMemory) {
logger.debug(`[NeuroLink] 🧠 LOG_POINT_G003_CONVERSATION_MEMORY_INIT_START`, {
logPoint: "G003_CONVERSATION_MEMORY_INIT_START",
generateInternalId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - generateInternalStartTime,
elapsedNs: (process.hrtime.bigint() - generateInternalHrTimeStart).toString(),
message: "Starting conversation memory initialization",
});
try {
await this.conversationMemory.initialize();
}
catch (err) {
logger.warn("[NEUROLINK] Redis memory init failed, falling back to in-memory", {
error: err instanceof Error ? err.message : String(err),
generateInternalId,
});
const memCfg = this.conversationMemoryConfig?.conversationMemory;
this.conversationMemory = new ConversationMemoryManager({
enabled: true,
maxSessions: memCfg?.maxSessions ?? 100,
maxTurnsPerSession: memCfg?.maxTurnsPerSession ?? 50,
});
await this.conversationMemory.initialize();
}
const conversationMemoryEndTime = process.hrtime.bigint();
const conversationMemoryDurationNs = conversationMemoryEndTime - conversationMemoryStartTime;
logger.debug(`[NeuroLink] ✅ LOG_POINT_G004_CONVERSATION_MEMORY_INIT_SUCCESS`, {
logPoint: "G004_CONVERSATION_MEMORY_INIT_SUCCESS",
generateInternalId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - generateInternalStartTime,
elapsedNs: (process.hrtime.bigint() - generateInternalHrTimeStart).toString(),
conversationMemoryDurationNs: conversationMemoryDurationNs.toString(),
conversationMemoryDurationMs: Number(conversationMemoryDurationNs) / NANOSECOND_TO_MS_DIVISOR,
message: "Conversation memory initialization completed successfully",
});
}
}
/**
* Attempt MCP generation with retry logic
*/
async attemptMCPGeneration(options, generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, functionTag) {
if (!options.disableTools &&
!(options.tts?.enabled && !options.tts?.useAiResponse)) {
return await this.performMCPGenerationRetries(options, generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, functionTag);
}
return null;
}
/**
* Non-retryable tool-error predicate shared by `performMCPGenerationRetries`
* and `tryMCPGeneration`. Returns `true` when the error indicates the model
* hallucinated a tool name or sent invalid arguments — retrying would
* re-trigger the same deterministic failure with the same tool set.
*
* Belt-and-suspenders: prefers class-identity `.isInstance()` (stable across
* Phase 5+ native substitution) but falls back to `error.name` and message
* substring checks so cross-module-boundary errors (Jest, bundlers, wrapped
* errors) are still caught. The name fallback covers both
* `AI_InvalidToolInputError` (current v5/v6 SDK) and the legacy
* `AI_InvalidToolArgumentsError` (v4).
*/
static isNonRetryableToolError(error) {
// Class-identity path: only meaningful when the throw really is an Error.
if (error instanceof Error &&
(NoSuchToolError.isInstance(error) ||
InvalidToolInputError.isInstance(error))) {
return true;
}
// Duck-type fallback for non-Error throws, wrapped errors, or values that
// serialize to JSON without preserving the prototype chain. Extract name
// and message defensively — `error` may be `null`, a string, or an object
// with non-string `name`/`message` properties.
const errAny = error;
const maybeName = errAny && typeof errAny.name === "string" ? errAny.name : "";
const maybeMessage = errAny && typeof errAny.message === "string"
? errAny.message
: String(error ?? "");
return (maybeName === "AI_NoSuchToolError" ||
maybeName === "AI_InvalidToolInputError" ||
maybeName === "AI_InvalidToolArgumentsError" ||
maybeMessage.includes("NoSuchToolError") ||
maybeMessage.includes("InvalidToolInputError") ||
maybeMessage.includes("InvalidToolArgumentsError") ||
maybeMessage.includes("Model tried to call unavailable tool"));
}
/**
* Perform MCP generation with retry logic
*/
async performMCPGenerationRetries(options, generateInternalId, generateInternalStartTime, generateInternalHrTimeStart, functionTag) {
const maxMcpRetries = RETRY_ATTEMPTS.QUICK;
// Captured BEFORE the first attempt: ensureMCPGenerationBudget (inside
// tryMCPGeneration) auto-scales options.timeout for >100K-token contexts
// when the caller left it unset, so checking options.timeout inside the
// catch below could not tell caller-set apart from auto-scaled.
const callerSetTimeout = options.timeout !== undefined;
// NL-007: Track retry metadata for observability
const retryErrors = [];
let retryCount = 0;
const maxAttempts = maxMcpRetries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (options.abortSignal?.aborted) {
logger.debug(`[${functionTag}] Abort signal already fired before attempt ${attempt}, stopping retries`);
throw new DOMException("The operation was aborted", "AbortError");
}
try {
logger.debug(`[${functionTag}] Attempting MCP generation (attempt ${attempt}/${maxAttempts})...`);
const mcpResult = await this.tryMCPGeneration(options);
if (mcpResult &&
(mcpResult.content ||
(mcpResult.toolExecutions && mcpResult.toolExecutions.length > 0))) {
logger.debug(`[${functionTag}] MCP generation successful on attempt ${attempt}`, {
contentLength: mcpResult.content?.length || 0,
toolsUsed: mcpResult.toolsUsed?.length || 0,
toolExecutions: mcpResult.toolExecutions?.length || 0,
retryCount,
});
// NL-007: Attach retry metadata to result
if (retryCount > 0) {
mcpResult.retries = { count: retryCount, errors: retryErrors };
}
return mcpResult;
}
else {
logger.debug(`[${functionTag}] MCP generation returned empty result on attempt ${attempt}`, {
hasResult: !!mcpResult,
hasContent: !!(mcpResult && mcpResult.content),
contentLength: mcpResult?.content?.length || 0,
toolExecutions: mcpResult?.toolExecutions?.length || 0,
});
}
}
catch (error) {
// Immediately propagate AbortError — never retry aborted requests
if (isAbortError(error)) {
logger.debug(`[${functionTag}] AbortError detected on attempt ${attempt}, stopping retries`);
throw error;
}
// A TimeoutError against an explicit caller-set options.timeout is
// deterministic-by-construction: every remaining retry AND the
// direct-generation fallback would re-run the same provider+model
// under the same per-step budget. Observed in production as ~650s
// of doomed follow-ups after a 90s step timeout. Surface it now.
// Name check (not instanceof): two TimeoutError classes exist
// (utils/timeout.ts and utils/async/withTimeout.ts) and both stamp
// name = "TimeoutError".
if (callerSetTimeout &&
error instanceof Error &&
error.name === "TimeoutError") {
logger.warn(`[${functionTag}] Per-step timeout (${String(options.timeout)}) exhausted on attempt ${attempt} — surfacing instead of retrying with the same budget`);
throw error;
}
// NL-007: Record retry error for observability
retryCount++;
const errMsg = error instanceof Error ? error.message : String(error);
const errCode = error instanceof NeuroLinkError
? error.code
: error instanceof Error
? error.name
: "UNKNOWN";
retryErrors.push({ code: errCode, message: errMsg.substring(0, 500) });
logger.debug(`[${functionTag}] MCP generation failed on attempt ${attempt}/${maxAttempts}`, {
error: errMsg,
willRetry: attempt < maxAttempts,
retryCount,
});
// Check for non-retryable errors — see NeuroLink.isNonRetryableToolError.
// Skipping remaining retries when the model hallucinated a tool name or
// sent bad arguments avoids re-triggering the same deterministic failure.
const isToolError = NeuroLink.isNonRetryableToolError(error);
const isNonRetryable = isContextOverflowError(error) ||
isToolError ||
isNonRetryableProviderError(error) ||
(error instanceof Error &&
error.isRetryable ===
false) ||
(error instanceof Error &&
error.statusCode === 400) ||
// A 429 already went through the provider layer's bounded,
// Retry-After-aware retry (withProviderRetry) — re-running the
// whole MCP generation against a rate-limited key is redundant
// and only delays fallback orchestration.
getErrorStatusCode(error) === 429;
if (isNonRetryable) {
logger.debug(`[${functionTag}] Non-retryable error detected, skipping remaining retries`);
break;
}
if (attempt >= maxAttempts) {
logger.debug(`[${functionTag}] All MCP attempts exhausted, falling back to direct generation`);
break;
}
await new Promise((resolve) => {
const timer = setTimeout(resolve, 500);
if (options.abortSignal) {
const onAbort = () => {
clearTimeout(timer);
resolve();
};
options.abortSignal.addEventListener("abort", onAbort, {
once: true,
});
}
});
if (options.abortSignal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
}
}
return null;
}
/**
* Try MCP-enhanced generation (no fallback recursion)
*/
async tryMCPGeneration(options) {
if (options.abortSignal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
// 🚀 EXHAUSTIVE LOGGING POINT T001: TRY MCP GENERATION ENTRY
const requestId = options.context?.requestId ||
"unknown";
const tryMCPId = `try-mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const tryMCPStartTime = Date.now();
const tryMCPHrTimeStart = process.hrtime.bigint();
const functionTag = "NeuroLink.tryMCPGeneration";
try {
const generationContext = await this.prepareMCPGenerationContext(options, requestId, tryMCPId, tryMCPStartTime, tryMCPHrTimeStart);
if (!generationContext) {
return null;
}
// Provider construction runs BEFORE the budget check so runtime
// model-limit discovery (ensureModelLimits) can register real context
// windows first — ensureMCPGenerationBudget otherwise computes against
// the static default window (see directProviderGeneration for the
// lock-out this ordering prevents).
const provider = await AIProviderFactory.createProvider(generationContext.providerName, options.model, !options.disableTools, this, options.region, this.resolveCredentials(options.credentials));
provider.setTraceContext(this._metricsTraceContext);
// Never rejects — discovery failure degrades to static defaults.
await provider.ensureModelLimits?.();
const conversationMessages = await this.ensureMCPGenerationBudget(options, requestId, generationContext.providerName, generationContext.enhancedSystemPrompt, generationContext.availableTools, generationContext.conversationMessages);
return this.generateWithMCPProvider({
options,
requestId,
functionTag,
tryMCPStartTime,
provider,
providerName: generationContext.providerName,
availableTools: generationContext.availableTools,
enhancedSystemPrompt: generationContext.enhancedSystemPrompt,
conversationMessages,
});
}
catch (error) {
// Immediately propagate AbortError — never swallow aborted requests
if (isAbortError(error)) {
mcpLogger.debug(`[${functionTag}] AbortError detected, rethrowing`);
throw error;
}
// Propagate non-retryable tool errors — see NeuroLink.isNonRetryableToolError.
// The caller's retry loop detects this and breaks immediately instead of
// retrying the same deterministic failure.
const isToolError = NeuroLink.isNonRetryableToolError(error);
if (isToolError) {
mcpLogger.warn(`[${functionTag}] Non-retryable tool error, rethrowing`, {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
mcpLogger.warn(`[${functionTag}] MCP generation failed`, {
error: error instanceof Error ? error.message : String(error),
});
return null; // Let caller fall back
}
}
async prepareMCPGenerationContext(options, requestId, tryMCPId, tryMCPStartTime, tryMCPHrTimeStart) {
await this.initializeMCP();
if (!this.mcpInitialized) {
logger.warn(`[NeuroLink] ⚠️ LOG_POINT_T004_MCP_NOT_AVAILABLE`, {
logPoint: "T004_MCP_NOT_AVAILABLE",
tryMCPId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - tryMCPStartTime,
elapsedNs: (process.hrtime.bigint() - tryMCPHrTimeStart).toString(),
mcpInitialized: this.mcpInitialized,
mcpComponents: {
hasExternalServerManager: !!this.externalServerManager,
hasToolRegistry: !!this.toolRegistry,
hasProviderRegistry: !!AIProviderFactory,
},
fallbackReason: "MCP_NOT_INITIALIZED",
message: "MCP not available - returning null for fallback to direct generation",
});
return null;
}
const providerName = options.provider === "auto" || !options.provider
? await getBestProvider()
: options.provider;
let availableTools = await this.getAllAvailableTools();
const { tools: circuitBreakerFilteredTools, unavailableTools } = this.toolRegistry.getAvailableTools(this.toolCircuitBreakers);
const cbFilteredNames = new Set(circuitBreakerFilteredTools.map((tool) => tool.name));
availableTools = availableTools.filter((tool) => cbFilteredNames.has(tool.name));
availableTools = this.applyToolInfoFiltering(availableTools, options);
const targetTool = availableTools.find((tool) => tool.name.includes("SuccessRateSRByTime") ||
tool.name.includes("juspay-analytics"));
logger.debug("Available tools for AI prompt generation", {
toolsCount: availableTools.length,
toolNames: availableTools.map((tool) => tool.name),
unavailableToolsCount: unavailableTools.length,
unavailableTools,
hasTargetTool: !!targetTool,
targetToolDetails: targetTool
? {
name: targetTool.name,
description: targetTool.description,
server: targetTool.server,
}
: null,
});
const circuitBreakerNote = unavailableTools.length > 0
? `\n\nNOTE: The following tools are temporarily unavailable due to repeated failures: ${unavailableTools.join(", ")}. Do not attempt to call these tools.`
: "";
// Circuit-breaker-open tools are ENFORCED out of the native tool set via
// the per-call denylist — asking the model nicely (the note above) is
// informational, exclusion is the guarantee. Previously only the
// system-prompt listing was filtered; the native array still shipped them.
if (unavailableTools.length > 0) {
options.excludeTools = [
...new Set([...(options.excludeTools ?? []), ...unavailableTools]),
];
}
// Providers with native tool calling receive full definitions via their
// `tools` parameter; only prompt-based providers still get the listing.
const nativeToolSupport = !isPromptOnlyToolProvider(String(providerName).toLowerCase());
const enhancedSystemPrompt = options.skipToolPromptInjection
? (options.systemPrompt || "") + circuitBreakerNote
: this.createToolAwareSystemPrompt(options.systemPrompt, availableTools, nativeToolSupport) + circuitBreakerNote;
logger.debug("Tool-aware system prompt created", {
requestId,
originalPromptLength: options.systemPrompt?.length || 0,
enhancedPromptLength: enhancedSystemPrompt.length,
skippedToolInjection: !!options.skipToolPromptInjection,
enhancedPromptPreview: enhancedSystemPrompt.substring(0, 80) + "...",
});
logger.debug("[Observability] System prompt metadata", {
requestId,
systemPromptLength: enhancedSystemPrompt.length,
systemPromptHash: enhancedSystemPrompt.length > 0
? `sha256:${enhancedSystemPrompt.slice(0, 8)}...`
: "empty",
hasCustomSystemPrompt: !!options.systemPrompt,
});
// Caller-supplied conversationMessages win — mirroring
// directProviderGeneration. getConversationMessages() returns [] whenever
// no memory manager / session context is configured, which silently
// dropped an inline history on the MCP-first path (the default path for
// any bare `new NeuroLink()` with tools enabled) while the direct path
// honored it.
const conversationMessages = (options.conversationMessages?.length
? options.conversationMessages
: await getConversationMessages(this.conversationMemory, options));
this.logMCPConversationSummary(requestId, conversationMessages);
logger.debug("[Observability] Available tools for LLM", {
requestId,
toolCount: availableTools.length,
toolNames: availableTools.map((tool) => tool.name),
});
return {
providerName,
availableTools,
enhancedSystemPrompt,
conversationMessages,
};
}
logMCPConversationSummary(requestId, conversationMessages) {
if (!logger.shouldLog("debug")) {
return;
}
try {
logger.debug("[Observability] Conversation history summary", {
requestId,
messageCount: conversationMessages.length,
messages: conversationMessages.map((message, index) => {
let contentLength;
if (typeof message.content === "string") {
contentLength = message.content.length;
}
else {
try {
contentLength = JSON.stringify(message.content).length;
}
catch {
contentLength = 0;
}
}
return {
index,
role: message.role,
contentLength,
contentPreview: typeof message.content === "string"
? message.content.substring(0, 200)
: "[multimodal]",
};
}),
});
}
catch {
// Ignore serialization errors in debug logging
}
}
async ensureMCPGenerationBudget(options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages) {
const budgetResult = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: enhancedSystemPrompt,
conversationMessages: conversationMessages,
currentPrompt: options.prompt,
toolDefinitions: availableTools,
compactionThreshold: options.compactionThreshold,
});
logger.info("[TokenBudget] Token breakdown", {
requestId,
system: budgetResult.breakdown?.systemPrompt || 0,
history: budgetResult.breakdown?.conversationHistory || 0,
tools: budgetResult.breakdown?.toolDefinitions || 0,
currentPrompt: budgetResult.breakdown?.currentPrompt || 0,
files: budgetResult.breakdown?.fileAttachments || 0,
total: budgetResult.estimatedInputTokens,
budget: budgetResult.availableInputTokens,
usagePercent: Math.round(budgetResult.usageRatio * 1000) / 10,
conversationMessageCount: conversationMessages.length,
shouldCompact: budgetResult.shouldCompact,
});
// Scale timeout for large contexts if caller didn't set one explicitly.
// Providers read options.timeout via getTimeout(), so setting it here
// propagates to any downstream provider call.
if (options.timeout === undefined &&
budgetResult.estimatedInputTokens > 100_000) {
// >100K → 1.5x, >200K → 2x, >300K → 2.5x (capped at 4x) of 60s base
const scale = 1 + Math.floor((budgetResult.estimatedInputTokens - 1) / 100_000) * 0.5;
const scaledMs = Math.round(60_000 * Math.min(scale, 4));
options.timeout = scaledMs;
logger.info("[TokenBudget] Scaled timeout for large context", {
requestId,
estimatedTokens: budgetResult.estimatedInputTokens,
scaledTimeoutMs: scaledMs,
});
}
const compactionSessionId = this.getCompactionSessionId(options);
const lastCompactionCount = this.lastCompactionMessageCount.get(compactionSessionId) ?? 0;
if (!budgetResult.shouldCompact ||
!this.conversationMemory ||
conversationMessages.length <= lastCompactionCount) {
return conversationMessages;
}
return this.compactMCPConversationForBudget({
options,
requestId,
providerName,
enhancedSystemPrompt,
availableTools,
conversationMessages,
availableInputTokens: budgetResult.availableInputTokens,
historyBudget: resolveHistoryBudget(budgetResult, options.compactionThreshold),
usageRatio: budgetResult.usageRatio,
estimatedInputTokens: budgetResult.estimatedInputTokens,
compactionSessionId,
});
}
async compactMCPConversationForBudget(context) {
const { options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages, availableInputTokens, historyBudget, usageRatio, estimatedInputTokens, compactionSessionId, } = context;
logger.info("[NeuroLink] Context budget exceeded, triggering auto-compaction", {
usageRatio,
estimatedTokens: estimatedInputTokens,
availableTokens: availableInputTokens,
});
const compactor = new ContextCompactor({
provider: providerName,
summarizationProvider: this.conversationMemoryConfig?.conversationMemory
?.summarizationProvider,
summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
}, this.contextCompactorDeps());
// Fixed overhead (system + prompt + tools + files) already exceeds the
// window — no amount of history compaction can fit this request, and
// compacting to an empty history would only hide the real cause.
if (historyBudget <= 0) {
throw new ContextBudgetExceededError(`Context exceeds model budget before any history is included. ` +
`System prompt, current prompt and tool definitions alone require ` +
`more than the ${availableInputTokens}-token input budget. ` +
`Reduce the tool set or the prompt size.`, {
estimatedTokens: estimatedInputTokens,
availableTokens: availableInputTokens,
stagesUsed: [],
breakdown: {},
});
}
const compactionResult = await compactor.compact(conversationMessages, historyBudget, this.conversationMemoryConfig?.conversationMemory, requestId, { currentRequest: options.prompt });
let compactedMessages = conversationMessages;
if (compactionResult.compacted) {
const repairedResult = repairToolPairs(compactionResult.messages);
compactedMessages = repairedResult.messages;
this.lastCompactionMessageCount.set(compactionSessionId, compactedMessages.length);
logger.info("[NeuroLink] Context compacted successfully", {
stagesUsed: compactionResult.stagesUsed,
tokensSaved: compactionResult.tokensSaved,
});
}
const postCompactBudget = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: enhancedSystemPrompt,
conversationMessages: compactedMessages,
currentPrompt: options.prompt,
toolDefinitions: availableTools,
});
if (postCompactBudget.withinBudget) {
return compactedMessages;
}
const overageRatio = postCompactBudget.usageRatio - 1.0;
logger.warn("[NeuroLink] Post-compaction still over budget, attempting emergency content truncation", {
requestId,
estimatedTokens: postCompactBudget.estimatedInputTokens,
availableTokens: postCompactBudget.availableInputTokens,
overagePercent: Math.round(overageRatio * 100),
stagesUsedInCompaction: compactionResult.stagesUsed,
});
compactedMessages = emergencyContentTruncation(compactedMessages, postCompactBudget.availableInputTokens, postCompactBudget.breakdown, providerName);
const finalBudget = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: enhancedSystemPrompt,
conversationMessages: compactedMessages,
currentPrompt: options.prompt,
toolDefinitions: availableTools,
});
if (!finalBudget.withinBudget) {
// Clear watermark so handleContextOverflow recovery can re-compact
this.lastCompactionMessageCount.delete(compactionSessionId);
throw new ContextBudgetExceededError(`Context exceeds model budget after all compaction stages. ` +
`Estimated: ${finalBudget.estimatedInputTokens} tokens, ` +
`Budget: ${finalBudget.availableInputTokens} tokens. ` +
`Conversation is too large to fit in the model's context window.`, {
estimatedTokens: finalBudget.estimatedInputTokens,
availableTokens: finalBudget.availableInputTokens,
stagesUsed: compactionResult.stagesUsed,
breakdown: finalBudget.breakdown,
});
}
return compactedMessages;
}
async generateWithMCPProvider(context) {
const { options, requestId, functionTag, tryMCPStartTime, provider, providerName, availableTools, enhancedSystemPrompt, conversationMessages, } = context;
this.emitter.emit("connected");
this.emitter.emit("message", `${providerName} provider initialized successfully`);
provider.setupToolExecutor({
customTools: this.getCustomTools(),
executeTool: (toolName, params) => this.executeTool(toolName, params, {
disableToolCache: options.disableToolCache,
}),
}, functionTag);
logger.debug("[Observability] User input to LLM", {
requestId,
promptPreview: options.prompt?.substring(0, 200),
promptLength: options.prompt?.length || 0,
model: options.model,
maxTokens: options.maxTokens,
temperature: options.temperature,
maxSteps: options.maxSteps,
skipToolPromptInjection: options.skipToolPromptInjection,
});
provider.setFileToolRootPolicy?.(this.fileToolRootsFor(options));
const result = await provider.generate({
...options,
systemPrompt: enhancedSystemPrompt,
conversationMessages,
});
const responseTime = Date.now() - tryMCPStartTime;
const hasContent = !!(result?.content && result.content.trim().length > 0);
const hasToolExecutions = !!(result?.toolExecutions && result.toolExecutions.length > 0);
mcpLogger.debug(`[${functionTag}] Result validation:`, {
hasResult: !!result,
hasContent,
hasToolExecutions,
contentLength: result?.content?.length || 0,
toolExecutionsCount: result?.toolExecutions?.length || 0,
toolsUsedCount: result?.toolsUsed?.length || 0,
});
if (!hasContent && !hasToolExecutions) {
mcpLogger.debug(`[${functionTag}] Result rejected: no content and no tool executions`);
return null;
}
const transformedToolExecutions = transformToolExecutionsForMCP(result.toolExecutions);
mcpLogger.debug(`[${functionTag}] Tool execution transformation:`, {
originalCount: result?.toolExecutions?.length || 0,
transformedCount: transformedToolExecutions.length,
transformedTools: transformedToolExecutions.map((te) => te.toolName),
});
return {
content: result.content || "",
provider: providerName,
model: result.model,
usage: result.usage,
responseTime,
finishReason: result.finishReason,
stopReason: result.stopReason,
rawFinishReason: result.rawFinishReason,
stepsUsed: result.stepsUsed,
toolsUsed: result.toolsUsed || [],
toolCalls: result.toolCalls ?? [],
toolExecutions: transformedToolExecutions,
// A tool RAN (toolsUsed), not merely dispatched (toolExecutions can
// include failed/never-found calls) — see hasToolExecutions above,
// which still gates the null-result check and must not change.
enhancedWithTools: !!(result.toolsUsed && result.toolsUsed.length > 0),
availableTools: transformToolsForMCP(transformToolsToExpectedFormat(availableTools)),
audio: result.audio,
ttsMetadata: result.ttsMetadata,
video: result.video,
avatar: result.avatar,
music: result.music,
ppt: result.ppt,
imageOutput: result.imageOutput,
analytics: result.analytics,
evaluation: result.evaluation,
// Forward reasoning from provider so callers asking for `result.reasoning`
// (DeepSeek `reasoning_content`, Anthropic thinking, Gemini thought parts,
// OpenAI o1) actually receive it.
reasoning: result.reasoning,
reasoningTokens: result.reasoningTokens,
// Propagate the native-emission flag so finalizeGenerateRequestResult
// skips the public top-level `generation:end` emission when the
// provider already emitted it itself (Vertex / Google AI Studio).
_generationEndEmitted: result
._generationEndEmitted,
};
}
/**
* Direct provider generation (no MCP, no recursion)
*/
async directProviderGeneration(options) {
const startTime = Date.now();
const functionTag = "NeuroLink.directProviderGeneration";
// Define provider priority for fallback
const providerPriority = [
"openai",
"vertex",
"bedrock",
"anthropic",
"azure",
"google-ai",
"huggingface",
"ollama",
];
const requestedProvider = options.provider === "auto" ? undefined : options.provider;
// Check for orchestrated preferred provider in context
const preferredOrchestrated = options.context &&
typeof options.context === "object" &&
"__orchestratedPreferredProvider" in options.context
? options.context
.__orchestratedPreferredProvider
: undefined;
// Build provider list with orchestrated preference first, then fallback to full list
const tryProviders = preferredOrchestrated
? [
preferredOrchestrated,
...providerPriority.filter((p) => p !== preferredOrchestrated),
]
: requestedProvider
? [requestedProvider]
: providerPriority;
// The caller owns fallback order (a providerFallback / modelChain caller,
// or a router that retries on its own): bound the walk to its first
// candidate so an unavailable provider surfaces as its own error instead
// of a silent switch. An explicit provider is already a one-element
// list; this only changes the "auto" and orchestrated-preference walks.
const providersToTry = options.disableInternalFallback === true
? tryProviders.slice(0, 1)
: tryProviders;
// Caller-owned fallback never enters tryProviders: providerFallback and
// modelChain are walked by runWithFallbackOrchestration around the public
// generate() call, and a configured ModelPool is consumed by the block
// above. Slicing here therefore never clips a caller's own list.
logger.debug(`[${functionTag}] Starting direct generation`, {
requestedProvider: requestedProvider || "auto",
preferredOrchestrated: preferredOrchestrated || "none",
tryProviders: providersToTry,
allowFallback: providersToTry.length > 1,
});
// ─── ModelPool path ──────────────────────────────────────────────────────
// When a ModelPool is configured, source the candidate sequence from the
// pool instead of (or in addition to) the static tryProviders list.
// Preserves the existing isNonRetryableProviderError short-circuit and
// AbortError propagation semantics. Falls through to the standard path
// when no pool is configured.
if (this.modelPool) {
const pool = this.modelPool;
const maxPoolAttempts = pool.maxAttempts;
const triedKeys = new Set();
let poolLastError = null;
for (let attempt = 0; attempt < maxPoolAttempts; attempt++) {
if (options.abortSignal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
const member = pool.selectNext(triedKeys);
if (!member) {
// All available members exhausted.
break;
}
triedKeys.add(pool.memberKey(member));
// Temporarily override provider/model/region from the pool member.
const savedProvider = options.provider;
const savedModel = options.model;
const savedRegion = options.region;
options.provider = member.provider;
// When the member omits model/region, clear inherited values so the
// provider's own default is used rather than a mismatched caller value.
options.model = member.model ?? undefined;
options.region = member.region ?? undefined;
const poolProviderName = member.provider;
logger.debug(`[${functionTag}] ModelPool: attempting member`, {
provider: poolProviderName,
model: member.model ?? options.model,
attempt,
});
try {
// Get conversation messages for context (use pre-compacted if provided)
const poolOptionsWithMessages = options;
const poolConversationMessages = poolOptionsWithMessages
.conversationMessages?.length
? poolOptionsWithMessages.conversationMessages
: await getConversationMessages(this.conversationMemory, options);
const poolProvider = await AIProviderFactory.createProvider(poolProviderName, options.model, !options.disableTools, this, options.region, this.resolveCredentials(options.credentials));
poolProvider.setTraceContext(this._metricsTraceContext);
poolProvider.setupToolExecutor({
customTools: this.getCustomTools(),
executeTool: (toolName, params) => this.executeTool(toolName, params, {
disableToolCache: options.disableToolCache,
}),
}, functionTag);
poolProvider.setFileToolRootPolicy?.(this.fileToolRootsFor(options));
const poolResult = await poolProvider.generate({
...options,
conversationMessages: poolConversationMessages,
});
if (!poolResult) {
throw new Error(`ModelPool: provider ${poolProviderName} returned null result`);
}
pool.recordSuccess(member);
const responseTime = Date.now() - startTime;
// Restore original options fields so callers see a clean object.
options.provider = savedProvider;
options.model = savedModel;
options.region = savedRegion;
return {
content: poolResult.content || "",
provider: poolProviderName,
model: poolResult.model,
usage: poolResult.usage,
responseTime,
finishReason: poolResult.finishReason,
stopReason: poolResult.stopReason,
rawFinishReason: poolResult.rawFinishReason,
stepsUsed: poolResult.stepsUsed,
toolsUsed: poolResult.toolsUsed || [],
toolCalls: poolResult.toolCalls ?? [],
// Lossless pass-through: keep the full ToolExecutionRecord
// fields (params/resultText/isError/timing) alongside the
// legacy {toolName,executionTime,success} shape this internal
// result declares, so the final GenerateResult mapping
// reconstructs real records instead of empty ones.
toolExecutions: poolResult.toolExecutions?.map((te) => ({
...te,
executionTime: te.durationMs,
success: !te.isError,
})),
// A tool RAN (toolsUsed), not merely dispatched.
enhancedWithTools: !!poolResult.toolsUsed?.length,
analytics: poolResult.analytics,
evaluation: poolResult.evaluation,
audio: poolResult.audio,
ttsMetadata: poolResult.ttsMetadata,
video: poolResult.video,
avatar: poolResult.avatar,
music: poolResult.music,
ppt: poolResult.ppt,
imageOutput: poolResult.imageOutput,
reasoning: poolResult.reasoning,
reasoningTokens: poolResult.reasoningTokens,
_generationEndEmitted: poolResult._generationEndEmitted,
};
}
catch (poolError) {
// Restore options unconditionally on failure.
options.provider = savedProvider;
options.model = savedModel;
options.region = savedRegion;
if (isAbortError(poolError)) {
throw poolError;
}
if (isNonRetryableForPool(poolError)) {
const poolErrMsg = poolError instanceof Error
? poolError.message
: String(poolError);
logger.warn(`[${functionTag}] ModelPool: non-retryable error from ${poolProviderName}, stopping pool`, { error: poolErrMsg });
pool.recordFailure(member, classifyProviderError(poolError));
// Wrap in a plain Error so runWithFallbackOrchestration does not
// mistake this for a ModelAccessDeniedError and start a second
// retry layer on top of the already-exhausted pool.
throw new Error(`[ModelPool] non-retryable: ${poolErrMsg}`, {
cause: poolError,
});
}
pool.recordFailure(member, classifyProviderError(poolError));
poolLastError =
poolError instanceof Error
? poolError
: new Error(String(poolError));
logger.warn(`[${functionTag}] ModelPool: member ${poolProviderName} failed`, { error: poolLastError.message });
}
}
// All pool members failed or were exhausted.
const poolResponseTime = Date.now() - startTime;
logger.error(`[${functionTag}] ModelPool: all members failed`, {
triedKeys: Array.from(triedKeys),
lastError: poolLastError?.message,
responseTime: poolResponseTime,
});
// Wrap in a plain Error (not a typed error class) so that
// runWithFallbackOrchestration does not re-activate the modelChain /
// providerFallback layer on top of an already-exhausted pool.
throw new Error(`[ModelPool] all members failed: ${poolLastError?.message ?? "no members available"}`);
}
// ─── End ModelPool path ───────────────────────────────────────────────────
let lastError = null;
// Try each provider in order
for (const providerName of providersToTry) {
if (options.abortSignal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
try {
logger.debug(`[${functionTag}] Attempting provider: ${providerName}`);
// Get conversation messages for context (use pre-compacted if provided)
const optionsWithMessages = options;
let conversationMessages = optionsWithMessages.conversationMessages
?.length
? optionsWithMessages.conversationMessages
: await getConversationMessages(this.conversationMemory, options);
// Provider construction runs BEFORE the budget check so runtime
// model-limit discovery (ensureModelLimits) can register real
// context windows first. The previous order created a lock-out:
// checkContextBudget ran against the static default window, its
// pre-dispatch hard cap threw before the provider (whose
// constructor owns the discovery) ever existed — so a blocked call
// prevented the very discovery that would have unblocked it.
const provider = await AIProviderFactory.createProvider(providerName, options.model, !options.disableTools, // Pass disableTools as inverse of enableMCP
this, // Pass SDK instance
options.region, // Pass region parameter
this.resolveCredentials(options.credentials));
// Propagate trace context for parent-child span hierarchy
provider.setTraceContext(this._metricsTraceContext);
// Never rejects — discovery failure degrades to static defaults.
await provider.ensureModelLimits?.();
// Pre-generation budget check
const budgetCheck = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: options.systemPrompt,
conversationMessages: conversationMessages,
currentPrompt: options.prompt,
toolDefinitions: options.tools
? Object.values(options.tools)
: undefined,
compactionThreshold: options.compactionThreshold,
});
const dpgMessageCount = conversationMessages?.length || 0;
const dpgCompactionSessionId = this.getCompactionSessionId(options);
// Curator P1-2: pre-dispatch compaction must run for inline
// `conversationMessages` too (not just conversationMemory). Without
// this, a 1.3M-token caller-supplied conversation against a 128K
// window dispatches anyway and the provider returns
// "prompt is too long" — the bug Curator's report cited.
const dpgHasInlineMessages = !!optionsWithMessages.conversationMessages?.length;
// Reviewer follow-up: gate the hard cap on the *actual compactable
// history* rather than `this.conversationMemory`. A configured-but-
// empty memory store leaves nothing to compact yet still satisfies
// `!this.conversationMemory === false`, so the previous check
// skipped the hard cap and dispatched the oversized payload.
const dpgHasCompactableMessages = dpgMessageCount > 0;
// Reviewer Finding #4: pre-dispatch hard cap for the standalone
// oversized case. When the budget check shows the request is
// over budget but there's nothing to compact (no memory + no
// inline messages — e.g. a huge prompt or huge tool definitions
// alone), recover by WINDOWING the prompt when the prompt is what
// blew the budget: keep its head and tail around an elision marker
// and dispatch. Agentic callers (Yama's session loop) carry their
// whole tool transcript in the prompt; the previous
// unconditional throw dead-ended every such turn — observed live
// as an unbounded retry loop (93K→299K tokens, no verdict, ever).
// The throw remains for the truly unrecoverable case: system
// prompt + tool definitions alone exceed the budget.
let promptWindowRecovered = false;
if (!budgetCheck.withinBudget && !dpgHasCompactableMessages) {
const fixedOverhead = (budgetCheck.breakdown?.systemPrompt ?? 0) +
(budgetCheck.breakdown?.toolDefinitions ?? 0) +
(budgetCheck.breakdown?.fileAttachments ?? 0);
// 3% margin against estimator drift; 1024-token floor — below
// that, a windowed prompt carries too little to answer from.
const promptBudget = Math.floor((budgetCheck.availableInputTokens - fixedOverhead) * 0.97);
const promptText = typeof options.prompt === "string" ? options.prompt : undefined;
if (promptText &&
promptBudget >= 1024 &&
(budgetCheck.breakdown?.currentPrompt ?? 0) > promptBudget) {
const marker = "\n\n[... middle of this prompt elided by NeuroLink to fit the model's context window ...]\n\n";
// Proportional char budget from the observed chars-per-token of
// THIS text, re-checked and shrunk until the estimator agrees.
let charBudget = Math.floor(promptText.length *
(promptBudget /
Math.max(budgetCheck.breakdown?.currentPrompt ?? 1, 1)));
let windowed = promptText;
for (let attempt = 0; attempt < 4; attempt++) {
const headChars = Math.floor(charBudget * 0.6);
const tailChars = Math.max(charBudget - headChars - marker.length, 0);
windowed =
promptText.slice(0, headChars) +
marker +
(tailChars > 0 ? promptText.slice(-tailChars) : "");
const recheck = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: options.systemPrompt,
conversationMessages: [],
currentPrompt: windowed,
toolDefinitions: options.tools
? Object.values(options.tools)
: undefined,
});
if (recheck.withinBudget) {
break;
}
charBudget = Math.floor(charBudget * 0.8);
if (attempt === 3) {
windowed = promptText; // give up — fall through to the throw
}
}
if (windowed !== promptText) {
logger.warn("[NeuroLink] Prompt exceeded the model's context budget with nothing to compact — " +
"windowed the prompt (head+tail kept, middle elided) to fit.", {
provider: providerName,
model: options.model,
estimatedTokens: budgetCheck.estimatedInputTokens,
budget: budgetCheck.availableInputTokens,
originalPromptChars: promptText.length,
windowedPromptChars: windowed.length,
});
try {
this.emitter.emit("compaction.applied", {
stagesAttempted: ["pre-dispatch prompt window"],
finalTokens: budgetCheck.availableInputTokens,
budget: budgetCheck.availableInputTokens,
provider: providerName,
model: options.model,
phase: "pre-dispatch-prompt-window",
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
options.prompt = windowed;
const inputHolder = options
.input;
if (inputHolder && typeof inputHolder.text === "string") {
inputHolder.text = windowed;
}
promptWindowRecovered = true;
}
}
if (!promptWindowRecovered) {
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: ["pre-dispatch hard cap"],
finalTokens: budgetCheck.estimatedInputTokens,
budget: budgetCheck.availableInputTokens,
provider: providerName,
model: options.model,
phase: "pre-dispatch-no-recovery",
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
throw new ContextBudgetExceededError(`Context exceeds model budget and no compaction is possible ` +
`(no conversationMemory, no inline conversationMessages — only ` +
`prompt + tools). Estimated: ${budgetCheck.estimatedInputTokens} ` +
`tokens, budget: ${budgetCheck.availableInputTokens} tokens. ` +
`Reduce prompt or tool-definition size, or trim the request.`, {
estimatedTokens: budgetCheck.estimatedInputTokens,
availableTokens: budgetCheck.availableInputTokens,
stagesUsed: [],
breakdown: budgetCheck.breakdown,
});
}
}
if (budgetCheck.shouldCompact &&
(this.conversationMemory || dpgHasInlineMessages) &&
dpgMessageCount >
(this.lastCompactionMessageCount.get(dpgCompactionSessionId) ?? 0)) {
const compactor = new ContextCompactor({
provider: providerName,
summarizationProvider: this.conversationMemoryConfig?.conversationMemory
?.summarizationProvider,
summarizationModel: this.conversationMemoryConfig?.conversationMemory
?.summarizationModel,
}, this.contextCompactorDeps());
const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(budgetCheck, options.compactionThreshold), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId, { currentRequest: options.prompt });
if (compactionResult.compacted) {
const repairedResult = repairToolPairs(compactionResult.messages);
conversationMessages = repairedResult.messages;
this.lastCompactionMessageCount.set(dpgCompactionSessionId, conversationMessages.length);
}
// POST-COMPACTION BUDGET RE-CHECK (BUG-003 fix)
const postCompactBudget = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: options.systemPrompt,
conversationMessages: conversationMessages,
currentPrompt: options.prompt,
toolDefinitions: options.tools
? Object.values(options.tools)
: undefined,
});
if (!postCompactBudget.withinBudget) {
logger.warn("[NeuroLink] directProviderGeneration: post-compaction still over budget, emergency truncation", {
estimatedTokens: postCompactBudget.estimatedInputTokens,
availableTokens: postCompactBudget.availableInputTokens,
overagePercent: Math.round((postCompactBudget.usageRatio - 1.0) * 100),
});
// Curator P1-2: emit `compaction.insufficient` whenever a
// single round of compaction wasn't enough — even when
// emergency truncation will save the day. Lets cost / audit
// listeners track the "compaction was insufficient" signal
// separately from the eventual outcome.
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: compactionResult.stagesUsed,
finalTokens: postCompactBudget.estimatedInputTokens,
budget: postCompactBudget.availableInputTokens,
provider: providerName,
model: options.model,
phase: "mid-compaction",
willEmergencyTruncate: true,
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
conversationMessages = emergencyContentTruncation(conversationMessages, postCompactBudget.availableInputTokens, postCompactBudget.breakdown, providerName);
const finalBudget = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: options.systemPrompt,
conversationMessages: conversationMessages,
currentPrompt: options.prompt,
toolDefinitions: options.tools
? Object.values(options.tools)
: undefined,
});
if (!finalBudget.withinBudget) {
// Clear watermark so handleContextOverflow recovery can re-compact
this.lastCompactionMessageCount.delete(dpgCompactionSessionId);
// Curator P1-2: emit `compaction.insufficient` so cost / audit
// listeners can record the specific failure mode (separate
// from a generic provider error).
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: compactionResult.stagesUsed,
finalTokens: finalBudget.estimatedInputTokens,
budget: finalBudget.availableInputTokens,
provider: providerName,
model: options.model,
phase: "post-emergency-truncation",
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
throw new ContextBudgetExceededError(`Context exceeds model budget after all compaction stages. ` +
`Estimated: ${finalBudget.estimatedInputTokens} tokens, ` +
`Budget: ${finalBudget.availableInputTokens} tokens.`, {
estimatedTokens: finalBudget.estimatedInputTokens,
availableTokens: finalBudget.availableInputTokens,
stagesUsed: compactionResult.stagesUsed,
breakdown: finalBudget.breakdown,
});
}
}
}
// ADD: Emit connection events for successful provider creation (Bedrock-compatible)
this.emitter.emit("connected");
this.emitter.emit("message", `${providerName} provider initialized successfully`);
// Enable tool execution for direct provider generation using BaseProvider method
provider.setupToolExecutor({
customTools: this.getCustomTools(),
executeTool: (toolName, params) => this.executeTool(toolName, params, {
disableToolCache: options.disableToolCache,
}),
}, functionTag);
provider.setFileToolRootPolicy?.(this.fileToolRootsFor(options));
const result = await provider.generate({
...options,
conversationMessages, // Inject conversation history
});
const responseTime = Date.now() - startTime;
if (!result) {
throw new Error(`Provider ${providerName} returned null result`);
}
logger.debug(`[${functionTag}] Provider ${providerName} succeeded`, {
responseTime,
contentLength: result.content?.length || 0,
});
return {
content: result.content || "",
provider: providerName,
model: result.model,
usage: result.usage,
responseTime,
finishReason: result.finishReason,
stopReason: result.stopReason,
rawFinishReason: result.rawFinishReason,
stepsUsed: result.stepsUsed,
toolsUsed: result.toolsUsed || [],
// The providers record executed calls; without this line the public
// result never carried them, whatever the provider returned.
toolCalls: result.toolCalls ?? [],
// Lossless pass-through: keep the full ToolExecutionRecord fields
// alongside the legacy {toolName,executionTime,success} shape this
// internal result declares, so the final GenerateResult mapping
// reconstructs real records instead of empty ones.
toolExecutions: result.toolExecutions?.map((te) => ({
...te,
executionTime: te.durationMs,
success: !te.isError,
})),
// A tool RAN (toolsUsed), not merely dispatched.
enhancedWithTools: !!result.toolsUsed?.length,
analytics: result.analytics,
evaluation: result.evaluation,
audio: result.audio,
ttsMetadata: result.ttsMetadata,
video: result.video,
avatar: result.avatar,
music: result.music,
ppt: result.ppt,
// CRITICAL FIX: Include imageOutput for image generation models
imageOutput: result.imageOutput,
// Forward reasoning so callers asking for `result.reasoning`
// (DeepSeek `reasoning_content`, Anthropic thinking, Gemini
// thought parts, OpenAI o1) actually receive it.
reasoning: result.reasoning,
reasoningTokens: result.reasoningTokens,
// Propagate native-emission flag — see attemptMCPGeneration comment.
_generationEndEmitted: result
._generationEndEmitted,
};
}
catch (error) {
// Immediately propagate AbortError — never fall back to next provider on abort
if (isAbortError(error)) {
logger.debug(`[${functionTag}] AbortError detected on provider ${providerName}, stopping fallback`);
throw error;
}
// Circuit breaker for non-retryable errors (model not found, auth failed, etc.)
// These errors are permanent — retrying with the same config will always fail
// and wastes tokens/latency (e.g., 6 retries of 418KB = ~628K wasted tokens)
if (isNonRetryableProviderError(error)) {
logger.warn(`[${functionTag}] Non-retryable error from provider ${providerName}, stopping fallback chain`, {
error: error instanceof Error ? error.message : String(error),
errorType: error instanceof Error ? error.constructor.name : typeof error,
});
throw error instanceof Error ? error : new Error(String(error));
}
lastError = error instanceof Error ? error : new Error(String(error));
logger.warn(`[${functionTag}] Provider ${providerName} failed`, {
error: lastError.message,
});
// Continue to next provider
}
}
// All providers failed
const responseTime = Date.now() - startTime;
logger.error(`[${functionTag}] All providers failed`, {
triedProviders: providersToTry,
lastError: lastError?.message,
responseTime,
});
// Reviewer follow-up: preserve typed ContextBudgetExceededError after
// the per-provider fallback loop. Each provider's hard cap is
// per-window; we let the loop try them all, but if every provider
// rejected on budget the caller still needs the typed error to
// distinguish "context too large" from a generic provider failure.
if (lastError instanceof ContextBudgetExceededError) {
throw lastError;
}
// A single-candidate list (an explicit provider, or
// disableInternalFallback) never actually tried "all providers" —
// naming the one provider that failed is clearer than the generic
// multi-provider wording. The multi-provider prefix is left untouched
// so existing message-matching suites keep passing.
// Both wrappers replace the provider's error with a new one, discarding
// anything attached to it. `_generationEndEmitted` has to survive: lose it
// and emitGenerateErrorEvent emits a second event for a failure the native
// provider already reported. Returns the error rather than throwing so each
// call site keeps its own `throw` — a never-returning helper reads better
// but tsconfig.cli.json will not accept it as terminating the enclosing
// function (TS2366). `cause` is set because the original error and its
// stack were simply being thrown away.
const wrapProviderError = (message) => {
const wrapped = new Error(message, { cause: lastError });
if (lastError &&
lastError._generationEndEmitted) {
wrapped._generationEndEmitted =
true;
}
return wrapped;
};
if (providersToTry.length === 1) {
throw wrapProviderError(`Provider ${providersToTry[0]} failed: ${lastError?.message || "Unknown error"}`);
}
throw wrapProviderError(`Failed to generate text with all providers. Last error: ${lastError?.message || "Unknown error"}`);
}
/**
* Create tool-aware system prompt that informs AI about available tools
*/
/**
* Apply per-call tool filtering (whitelist/blacklist) to a ToolInfo array.
* Used to filter the tool list before building the system prompt.
*
* Resolves the SAME policy as the native gate (`BaseProvider`
* `applyToolFiltering`) — per-call options plus the instance-level `tools`
* config — so what the model is told about tools never diverges from the
* tools it can actually call.
*/
applyToolInfoFiltering(tools, options) {
const policy = resolveToolPolicy({
options: {
disableTools: options.disableTools,
toolFilter: options.toolFilter,
enabledToolNames: options.enabledToolNames,
excludeTools: options.excludeTools,
},
instanceConfig: this.toolsConfig,
builtinToolNames: Object.keys(directAgentTools),
});
// Null prototype + own-property checks: a tool named "__proto__" must
// become an own entry — with a plain `{}`, `"__proto__" in record` is
// truthy via the prototype and the tool would be silently dropped from
// the listing while surviving the native gate. Matches the hardening on
// every other record in the tool-resolution pipeline.
const record = Object.create(null);
for (const tool of tools) {
if (!Object.hasOwn(record, tool.name)) {
record[tool.name] = tool;
}
}
const gated = applyToolGate(record, policy);
const filtered = tools.filter((t) => Object.hasOwn(gated, t.name));
if (filtered.length !== tools.length) {
logger.debug(`Tool info filtering applied for system prompt`, {
beforeCount: tools.length,
afterCount: filtered.length,
policySources: policy.sources,
toolFilter: options.toolFilter,
excludeTools: options.excludeTools,
});
}
return filtered;
}
createToolAwareSystemPrompt(originalSystemPrompt, availableTools, nativeToolSupport) {
// AI prompt generation with tool analysis and structured logging
const promptGenerationData = {
originalPromptLength: originalSystemPrompt?.length || 0,
availableToolsCount: availableTools.length,
hasOriginalPrompt: !!originalSystemPrompt,
nativeToolSupport,
};
logger.debug("AI prompt generation with tool schemas", promptGenerationData);
if (availableTools.length === 0) {
logger.debug("No tools available - returning original prompt");
return originalSystemPrompt || "";
}
if (nativeToolSupport) {
// The provider receives full tool definitions natively via its `tools`
// parameter — repeating name/description/parameters here was pure
// duplication (~850 tokens on the default surface, tens of thousands
// with MCP servers attached). Keep only a short, static (cache-stable)
// damping line so models don't over-reach for tools.
return ((originalSystemPrompt || "") +
"\n\nTools are available via native tool calling. Use them only when they genuinely improve your response; for creative or conversational requests, respond naturally without tools.");
}
const toolDescriptions = transformToolsToDescriptions(availableTools.map((t) => ({
name: t.name,
description: t.description ?? "",
server: t.serverId ?? "unknown",
inputSchema: t.inputSchema,
})));
const transformationResult = {
toolDescriptionsLength: toolDescriptions.length,
toolDescriptionsCharCount: toolDescriptions.length,
hasDescriptions: toolDescriptions.length > 0,
};
logger.debug("Tool descriptions transformation completed", transformationResult);
const toolPrompt = `\n\nYou have access to these additional tools if needed:\n${toolDescriptions}\n\nIMPORTANT: You are a general-purpose AI assistant. Answer all requests directly and creatively. These tools are optional helpers - use them only when they would genuinely improve your response. For creative tasks like storytelling, writing, or general conversation, respond naturally without requiring tools.`;
const finalPrompt = (originalSystemPrompt || "") + toolPrompt;
const finalPromptData = {
originalPromptLength: originalSystemPrompt?.length || 0,
toolPromptLength: toolPrompt.length,
finalPromptLength: finalPrompt.length,
promptEnhanced: toolPrompt.length > 0,
};
logger.debug("AI prompt generation completed", finalPromptData);
return finalPrompt;
}
/**
* Execute tools if available through centralized registry
* Simplified approach without domain detection - relies on tool registry
*/
async detectAndExecuteTools(prompt, _domainType) {
const functionTag = "NeuroLink.detectAndExecuteTools";
try {
// Simplified: Just return original prompt without complex detection
// Tools will be available through normal MCP flow when AI decides to use them
logger.debug(`[${functionTag}] Skipping automatic tool execution - relying on centralized registry`);
return { toolResults: [], enhancedPrompt: prompt };
}
catch (error) {
logger.error(`[${functionTag}] Tool detection/execution failed`, {
error: error instanceof Error ? error.message : String(error),
});
return { toolResults: [], enhancedPrompt: prompt };
}
}
/**
* BACKWARD COMPATIBILITY: Legacy streamText method
* Internally calls stream() and converts result format
*/
async streamText(prompt, options) {
// Convert legacy format to new StreamOptions
const streamOptions = {
input: { text: prompt },
...options,
};
// Call the new stream method
const result = await this.stream(streamOptions);
// Convert StreamResult to simple string async iterable (filter text events only)
async function* stringStream() {
for await (const evt of result.stream) {
const anyEvt = evt;
if (anyEvt && typeof anyEvt === "object" && "content" in anyEvt) {
const content = anyEvt.content;
if (typeof content === "string") {
yield content;
}
}
}
}
return stringStream();
}
/**
* Stream AI-generated content in real-time using the best available provider.
* This method provides real-time streaming of AI responses with full MCP tool integration.
*
* @param options - Stream configuration options
* @param options.input - Input configuration object
* @param options.input.text - The text prompt to send to the AI (required)
* @param options.provider - AI provider to use ('auto', 'openai', 'anthropic', etc.)
* @param options.model - Specific model to use (e.g., 'gpt-4', 'claude-3-opus')
* @param options.temperature - Randomness in response (0.0 = deterministic, 2.0 = very random)
* @param options.maxTokens - Maximum tokens in response
* @param options.systemPrompt - System message to set AI behavior
* @param options.disableTools - Whether to disable MCP tool usage
* @param options.enableAnalytics - Whether to include usage analytics
* @param options.enableEvaluation - Whether to include response quality evaluation
* @param options.context - Additional context for the request
* @param options.evaluationDomain - Domain for specialized evaluation
* @param options.useKnowledgeGrounding - Whether to use the instance's configured knowledge for this call
*
* @returns Promise resolving to StreamResult with an async iterable stream
*
* @example
* ```typescript
* // Basic streaming usage
* const result = await neurolink.stream({
* input: { text: "Tell me a story about space exploration" }
* });
*
* // Consume the stream
* for await (const chunk of result.stream) {
* if ("content" in chunk) {
* process.stdout.write(chunk.content);
* }
* }
*
* // Advanced streaming with options
* const result = await neurolink.stream({
* input: { text: "Explain machine learning" },
* provider: "openai",
* model: "gpt-4",
* temperature: 0.7,
* enableAnalytics: true,
* context: { domain: "education", audience: "beginners" }
* });
*
* // Access metadata and analytics
* console.log(result.provider);
* console.log(result.analytics?.usage);
* ```
*
* @throws {Error} When input text is missing or invalid
* @throws {Error} When all providers fail to generate content
* @throws {Error} When conversation memory operations fail (if enabled)
*/
async stream(options) {
return logger.runInInstanceScope(this.logInstanceId, () => this.streamInInstanceScope(options));
}
/**
* Body of {@link stream}, always entered through the instance log scope.
*
* Note the scope covers setting the stream up, not draining it: logs
* emitted while the consumer iterates the returned stream run in the
* consumer's async context, so they are attributed to whatever scope the
* consumer is in. Provider loops that run to completion inside this call
* are covered.
*/
async streamInInstanceScope(options) {
// Host-loop delegation (registerAgentTool): the same per-turn scope as
// generate() — delegation caps count against THIS streamed turn and
// depth-limited agent tools are withheld from the request. The provider
// stream loops start inside this call, so the ALS scope propagates into
// their tool executions. beginDelegationTurn returns null when a scope
// is already active (the re-entrant call below shares the counters).
if (this.hasAgentTools) {
const { beginDelegationTurn } = await import("./agent/agentToolRegistrar.js");
const scope = beginDelegationTurn(this, options);
if (scope) {
return scope.run(() => this.stream(scope.options));
}
}
logger.debug("[NeuroLink] stream() called with options", {
provider: options.provider,
model: options.model,
inputLength: options.input?.text?.length || 0,
disableTools: options.disableTools,
enableAnalytics: options.enableAnalytics,
enableEvaluation: options.enableEvaluation,
contextKeys: options.context
? Object.keys(options.context ?? {})
: [],
optionKeys: Object.keys(options),
});
// Defensive shallow clone of top-level options + the nested mutable
// branches that downstream stages (prepareStreamOptions's memory
// retrieval at `options.input.text`, applyStreamOrchestration's merge,
// RAG/MCP tool injection, etc.) mutate. Cloning at the entry point
// means callers can reuse a single options object across stream()
// calls without accumulating mutations across them — the earlier
// shallow rebind at the orchestration site only covered the
// top-level keys and left `options.input` shared with the caller.
options = cloneOptionsForCallIsolation(options);
let agentModeVersion;
{
const { agentMode, ...rest } = options;
if (agentMode !== undefined) {
const resolved = resolveTerminalAgentMode(agentMode, rest.systemPrompt);
agentModeVersion = resolved.version;
options = { ...rest, systemPrompt: resolved.systemPrompt };
}
}
bindFileToolRoots(options, this.resolveFileToolRoots(options.toolRoots));
const startedAt = Date.now();
// Retrieve once before provider/model fallback orchestration. Every fallback
// receives the same explicitly enriched options, without the retrieval helper
// mutating its input or injecting the same block more than once.
const knowledgeOutcome = await this.retrieveKnowledgeGrounding(options);
if (knowledgeOutcome?.ephemeralContext) {
const block = knowledgeOutcome.ephemeralContext.content;
options = {
...options,
systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(options.systemPrompt, block),
};
}
try {
const result = await this.streamWithIterationFallback(options);
if (knowledgeOutcome) {
result.knowledge = knowledgeOutcome.metadata;
}
if (agentModeVersion) {
result.agentModeVersion = agentModeVersion;
}
return result;
}
catch (error) {
// Mirror generate(): fire consumer onError for failures that
// happened before the wrapped language-model middleware could
// observe them (e.g. unknown provider, validation, factory
// exceptions). The shared Symbol marker (lifecycleCallbacks.ts)
// prevents double-fire when the AI-SDK lifecycle middleware
// already handled the error. Awaited so async handlers fully
// settle before stream() rethrows.
await this.fireConsumerOnErrorIfNotFired(options, error, startedAt);
throw error;
}
}
/**
* Curator P2-3 / Reviewer Finding #2: stream-fallback that also covers
* errors thrown during async iteration (e.g. LiteLLM throwing inside
* `createLiteLLMTransformedStream`). The standard
* `runWithFallbackOrchestration` only catches errors thrown while the
* `StreamResult` is being created — once we hand the iterator back to
* the caller, errors raised during consumption used to bypass
* `providerFallback` / `modelChain`.
*
* This wrapper runs the orchestration to get an initial StreamResult,
* then wraps `result.stream` so that:
* - chunks are forwarded transparently while consumption succeeds
* - if iteration throws a model-access-denied error AND no chunks
* have been yielded yet, we resolve the next fallback target,
* emit `model.fallback`, and recurse
* - if chunks were already yielded, the error propagates (mid-stream
* recovery isn't safe — the consumer has half a response)
*/
async streamWithIterationFallback(options) {
const result = await this.runWithFallbackOrchestration(options, "stream", (opts) => metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeStreamRequest({ ...opts })));
const callOpts = options;
const perCallCallback = callOpts.providerFallback;
const perCallChain = callOpts.modelChain;
const effectiveCallback = perCallCallback ?? this.fallbackConfig.providerFallback;
const effectiveChain = perCallChain ?? this.fallbackConfig.modelChain;
if (!effectiveCallback && !effectiveChain) {
// No fallback configured — nothing to wrap.
return result;
}
// Build a chain cursor scoped to this stream's lifetime; consumers
// who set up `modelChain` get sequential progression here too.
const chainCursor = {
i: 0,
list: effectiveChain ?? [],
requestedModel: options.model,
};
const callback = effectiveCallback ??
(async () => {
while (chainCursor.i < chainCursor.list.length) {
const next = chainCursor.list[chainCursor.i++];
if (next !== chainCursor.requestedModel) {
return { model: next };
}
}
return null;
});
const self = this;
// Yield type is the original stream's element type, threaded through
// as unknown — we forward chunks unchanged so structural identity is
// preserved without a local type alias (CLAUDE.md rule 2).
const wrappedStream = (async function* () {
let yielded = 0;
let currentResult = result;
let attemptedRequestedProvider = options.provider;
let attemptedRequestedModel = options.model;
const maxAttempts = (effectiveChain?.length ?? 0) + 5;
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
try {
for await (const chunk of currentResult.stream) {
yielded++;
yield chunk;
}
return;
}
catch (err) {
if (yielded > 0 || !looksLikeModelAccessDenied(err)) {
throw err;
}
let next;
try {
next = await callback(err);
}
catch (cbErr) {
logger.warn("[NeuroLink.stream] providerFallback callback threw during iteration", {
error: cbErr instanceof Error ? cbErr.message : String(cbErr),
});
throw err;
}
if (!next) {
throw err;
}
try {
self.emitter.emit("model.fallback", {
requestedProvider: attemptedRequestedProvider,
requestedModel: attemptedRequestedModel,
fallbackProvider: next.provider ?? attemptedRequestedProvider,
fallbackModel: next.model,
reason: err instanceof Error ? err.message : String(err),
kind: "stream",
phase: "iteration",
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
const retriedOptions = {
...options,
...(next.provider && {
provider: next.provider,
}),
...(next.model && { model: next.model }),
// Strip the hooks so the inner orchestration doesn't double-fall-back.
providerFallback: undefined,
modelChain: undefined,
};
attemptedRequestedProvider =
next.provider ?? attemptedRequestedProvider;
attemptedRequestedModel = next.model ?? attemptedRequestedModel;
currentResult = await metricsTraceContextStorage.run(self.createMetricsTraceContext(), () => self.executeStreamRequest({ ...retriedOptions }));
}
}
// Exhausted attempts — re-throw the most recent error captured by
// the inner loop. We only get here if the loop didn't return.
throw new Error(`[NeuroLink.stream] iteration fallback exhausted ${maxAttempts} attempts`);
})();
// `result`'s toolsUsed/toolExecutions may be live getters (openai-compat
// providers back them with a getter over an array still being populated
// by a background loop) — a naked spread here would read them once, now,
// and freeze whatever they held at that instant. preserveLiveStreamAccessors
// re-applies the original getter descriptors onto the rebuilt object, the
// same fix already applied to withStreamModelFallback/
// wrapStreamWithLifecycleCallbacks in baseProvider.ts.
return preserveLiveStreamAccessors(result, {
...result,
stream: wrappedStream,
});
}
async executeStreamRequest(options) {
// Dynamic argument resolution — resolve any function-valued options before downstream use
await this.resolveDynamicOptions(options);
const streamSpan = tracers.sdk.startSpan("neurolink.stream", {
kind: SpanKind.INTERNAL,
attributes: {
[ATTR.NL_PROVIDER]: options.provider || "default",
[ATTR.GEN_AI_MODEL]: options.model || "default",
[ATTR.NL_INPUT_LENGTH]: options.input?.text?.length || 0,
// Count registered custom tools too — chat hosts put their MCP tools
// in the registry, so options.tools alone under-reports.
[ATTR.NL_HAS_TOOLS]: !options.disableTools &&
(!!(options.tools && Object.keys(options.tools).length > 0) ||
this.getCustomTools().size > 0),
[ATTR.NL_STREAM_MODE]: true,
},
});
// streamSpan isn't active yet, so context.active() is its parent — empty =
// root. Capture root-ness here, but defer the actual guest-rescue stamp to
// after validateStreamRequestOptions merges auth/requestContext identity
// into options.context (below) — otherwise an auth:{token} caller with no
// pre-set context.userId would stamp the root span as guest.
const streamIsRoot = !trace.getSpan(context.active());
const spanStartTime = Date.now();
this._disableToolCacheForCurrentRequest = !!options.disableToolCache;
this._toolCacheKeysServedThisRequest = new Set();
this._generationTurnActive = true;
try {
options.model = resolveModel(options.model, this.modelAliasConfig);
const startTime = Date.now();
const hrTimeStart = process.hrtime.bigint();
const streamId = `neurolink-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// STT preprocessing for stream(): transcribe audio buffer (not realtime frames)
// and inject into the prompt before validation/execution. Mirrors generate().
const sttOptions = options.stt;
const sttAudio = sttOptions?.audio;
const hasStreamSttAudio = !!(sttOptions?.enabled && sttAudio);
let streamSttTranscription;
if (hasStreamSttAudio && sttOptions && sttAudio) {
if (!options.input) {
options.input = { text: "" };
}
try {
// registerAllProviders() is idempotent; always call.
await ProviderRegistry.registerAllProviders();
const { STTProcessor } = await import("./utils/sttProcessor.js");
const sttProvider = sttOptions.provider ?? "whisper";
streamSttTranscription = await STTProcessor.transcribe(sttAudio, sttProvider, sttOptions);
if (streamSttTranscription.text) {
const existingText = options.input.text || "";
options.input.text = existingText
? `[Transcribed audio]: ${streamSttTranscription.text}\n\n${existingText}`
: streamSttTranscription.text;
}
}
catch (sttError) {
const existingText = options.input.text || "";
if (!existingText) {
throw sttError;
}
logger.warn(`[NeuroLink] Stream STT transcription failed, falling back to text: ${sttError instanceof Error ? sttError.message : String(sttError)}`);
}
}
const originalPrompt = options.input?.text ?? "";
options.fileRegistry = this.fileRegistry;
await this.validateStreamRequestOptions(options, startTime);
// options.context now carries any auth/requestContext-derived identity.
stampGuestRescueIdentity(streamSpan, options.context, streamIsRoot);
const workflowResult = await this.maybeHandleWorkflowStreamRequest({
options,
startTime,
streamSpan,
spanStartTime,
});
if (workflowResult) {
return workflowResult;
}
// Make neurolink.stream the active span so every provider span (generations,
// tool calls) parents under it — one Langfuse trace per turn, not a forest.
const streamSpanContext = trace.setSpan(context.active(), streamSpan);
// Pre-call tool routing: run inside the stream-span + Langfuse context so
// the router's own generation span nests under this turn's trace instead
// of starting a separate one. Asks a cheap router LLM which tool servers
// the query needs and appends the unpicked servers' tools to
// `excludeTools`. Fails open (no exclusions). Routes on the current
// prompt enriched with a bounded window of recent conversation turns
// (pulled from conversation memory) so contextless follow-ups still
// classify correctly. After the workflow short-circuit, so workflow
// streams skip it.
await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.applyToolRoutingExclusions(options, originalPrompt)));
// Pre-call classifier router for stream: opt-in, fails open. Runs before
// the request router so it takes precedence.
await this.applyClassifierRouting(options, originalPrompt, !options.disableTools &&
(!!(options.tools && Object.keys(options.tools).length > 0) ||
this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
.thinking?.thinkingLevel);
// Pre-call request router for stream: opt-in, fails open.
await this.applyRequestRouter(options, originalPrompt, !options.disableTools &&
(!!(options.tools && Object.keys(options.tools).length > 0) ||
this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
.thinking?.thinkingLevel);
// Streaming TTS deferred: stream() always synthesizes the streamed AI
// response when TTS is enabled, regardless of generate()'s
// input-vs-response `useAiResponse` switch. It resolves
// `streamResult.audio` with the aggregate TTSResult. The resolver is
// plumbed explicitly through the params bag (M11: previously a
// `_streamTtsResolve` cast on the caller's options object — fragile if
// the same options object was reused across concurrent stream() calls).
const ttsOptions = options.tts;
const wantsStreamTtsMode2 = ttsOptions?.enabled === true;
let resolveStreamTtsAudio;
let streamTtsMetadata;
const streamTtsAudioPromise = wantsStreamTtsMode2
? new Promise((resolve) => {
resolveStreamTtsAudio = resolve;
})
: undefined;
const streamResult = await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.runStandardStreamRequest({
options,
streamSpan,
spanStartTime,
startTime,
hrTimeStart,
streamId,
originalPrompt,
ttsResolver: resolveStreamTtsAudio,
ttsMetadataSink: (metadata) => {
streamTtsMetadata = metadata;
},
})));
if (streamSttTranscription) {
streamResult.transcription = streamSttTranscription;
}
if (streamTtsAudioPromise) {
streamResult.audio = streamTtsAudioPromise;
streamResult.ttsMetadata = streamTtsMetadata;
}
return streamResult;
}
catch (error) {
streamSpan.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
if (error instanceof Error) {
streamSpan.recordException(error);
}
streamSpan.end();
throw error;
}
}
/**
* Pre-call tool routing for both stream() and generate() turns: runs the
* router LLM once per turn and appends the unpicked servers' registered tool
* names to `options.excludeTools` — the per-call denylist enforced by
* `baseProvider.applyToolFiltering`. No-op unless `toolRouting.enabled` is
* true and a non-empty server catalog has been supplied. Never throws (the
* resolver fails open to an empty exclusion list). Accepts both StreamOptions
* and GenerateOptions since both share the required fields.
*/
async applyToolRoutingExclusions(options, userQuery) {
const routingConfig = this.toolRoutingConfig;
if (!routingConfig?.enabled || options.disableTools) {
return;
}
const servers = routingConfig.servers ?? [];
if (servers.length === 0) {
return;
}
// Whole setup is fail-open: catalog building (getCustomTools /
// buildToolRoutingCatalog) and the router call degrade to no exclusions
// rather than killing the stream/generate turn, honoring this method's
// "never throws" contract. Genuine cancellations still propagate.
try {
const registeredToolNames = Array.from(this.getCustomTools().keys());
const catalog = buildToolRoutingCatalog(servers, registeredToolNames);
if (catalog.length === 0) {
return;
}
// Fold a bounded window of recent conversation turns into the routing query.
// The router runs pre-memory and would otherwise see only this turn's raw
// text, so a contextless follow-up ("yes please") gives it nothing to
// classify — it fails open and routing narrows nothing. The main model
// still receives full history later via conversation memory; this only
// enriches the router's view. Fails open to the current query alone.
const recentMessages = await this.fetchRecentRoutingHistory(options);
const routingQuery = recentMessages.length > 0
? buildRoutingQueryFromHistory(recentMessages, userQuery)
: userQuery;
// --- ITEM C: routing decision cache ---
const cacheConfig = routingConfig.cache;
const cacheEnabled = cacheConfig?.enabled === true;
const stickinessEnabled = routingConfig.stickiness?.enabled === true;
// Lazy-init the cache instance once per NeuroLink instance.
if ((cacheEnabled || stickinessEnabled) &&
!this.toolRoutingCacheInstance) {
this.toolRoutingCacheInstance = new ToolRoutingCache({
ttlMs: cacheConfig?.ttlMs,
maxEntries: cacheConfig?.maxEntries,
stickyTurns: routingConfig.stickiness?.turns,
});
}
const cache = this.toolRoutingCacheInstance;
// Derive sessionId for stickiness — same extraction path as
// fetchRecentRoutingHistory.
const requestContext = options.context;
const sessionId = typeof requestContext?.sessionId === "string"
? requestContext.sessionId
: "";
// Cache key: session + normalized routing query.
// Require a non-empty sessionId to avoid anonymous sessions sharing a
// ":query" namespace (cross-session cache leak).
const cacheKey = cacheEnabled && sessionId ? `${sessionId}:${routingQuery}` : undefined;
const routableServerCount = catalog.filter((s) => !(routingConfig.alwaysIncludeServerIds ?? []).includes(s.id)).length;
// --- ITEM E: build the emitDecision callback ---
// Constructed BEFORE the cache-hit check so it is available for all
// outcome paths, including cache-hit turns (Finding 4).
const routingStartTime = Date.now();
const emitDecision = (decision) => {
try {
const activeSpan = trace.getActiveSpan();
if (!activeSpan) {
return;
}
activeSpan.setAttribute("tool_routing.outcome", decision.outcome);
activeSpan.setAttribute("tool_routing.routable_server_count", decision.routableServerCount);
activeSpan.setAttribute("tool_routing.selected_server_ids", spanJsonAttribute(decision.selectedServerIds));
activeSpan.setAttribute("tool_routing.excluded_server_ids", spanJsonAttribute(decision.excludedServerIds));
activeSpan.setAttribute("tool_routing.hallucinated_ids", spanJsonAttribute(decision.hallucinatedIds));
activeSpan.setAttribute("tool_routing.excluded_tool_count", decision.excludedToolCount);
activeSpan.setAttribute("tool_routing.cache_hit", decision.cacheHit);
activeSpan.setAttribute("tool_routing.duration_ms", decision.durationMs);
// Optional L2 / tool-granularity fields (present only when embedding ran).
if (decision.embeddingActivated !== undefined) {
activeSpan.setAttribute("tool_routing.embedding_activated", decision.embeddingActivated);
}
if (decision.candidateToolCount !== undefined) {
activeSpan.setAttribute("tool_routing.candidate_tool_count", decision.candidateToolCount);
}
if (decision.granularity !== undefined) {
activeSpan.setAttribute("tool_routing.granularity", decision.granularity);
}
// Which router actually decided. Three strategies can produce an
// "applied" outcome and they have wildly different cost and
// latency profiles, so an outcome without a strategy cannot be
// acted on — and a decision router that silently stopped firing
// looks identical to one that was never configured.
if (decision.strategy !== undefined) {
activeSpan.setAttribute("tool_routing.strategy", decision.strategy);
}
}
catch {
// Telemetry must never affect routing behaviour.
}
};
// Check the cache first.
if (cacheEnabled && cache && cacheKey !== undefined) {
const cached = cache.get(cacheKey);
if (cached) {
// Decrement stickiness turn counter even on a cache hit so the window
// advances correctly regardless of whether the LLM router ran
// (Finding 3). Re-apply stickiness to the cached exclusion list so
// the live stickiness window, not the one at write-time, is honoured
// (Finding 2 complement: we stored the pre-stickiness list, so
// re-applying here gives the correct per-turn view).
let cachedExcluded = cached.excludedToolNames;
if (stickinessEnabled && sessionId) {
try {
const stickyIds = cache.getStickyServerIds(sessionId);
if (stickyIds.length > 0) {
cachedExcluded = cachedExcluded.filter((toolName) => !stickyIds.some((id) => toolName.startsWith(`${id}_`)));
}
}
catch {
// Stickiness failure is non-fatal.
}
}
// Notify the emitDecision callback so custom telemetry sinks observe
// every routing outcome, not just non-cached turns (Finding 4).
const cachedSelectedSet = new Set(cached.selectedServerIds);
const cachedExcludedServerIds = catalog
.map((e) => e.id)
.filter((id) => !cachedSelectedSet.has(id));
emitDecision({
outcome: "cache-hit",
selectedServerIds: cached.selectedServerIds,
excludedServerIds: cachedExcludedServerIds,
hallucinatedIds: [],
excludedToolCount: cachedExcluded.length,
routableServerCount,
cacheHit: true,
durationMs: Date.now() - routingStartTime,
});
logger.debug("[ToolRouting] Cache hit, skipping router LLM", {
hasSessionId: !!sessionId,
routingQueryLength: routingQuery.length,
});
if (cachedExcluded.length > 0) {
options.excludeTools = [
...(options.excludeTools ?? []),
...cachedExcluded,
];
}
return;
}
}
// The router call below re-enters the public generate(), whose finally
// block resets _disableToolCacheForCurrentRequest to false. That flag is
// turn-scoped (set at the top of this turn) and read by the main tool
// execution path that runs after routing, so save it before the router
// call and restore it afterward to keep the turn's cache setting intact.
const cacheDisabledForCurrentRequest = this._disableToolCacheForCurrentRequest;
let routedExcludeTools;
let resolvedDecision;
try {
// Intercept the decision so we can store it in the cache.
const captureDecision = (decision) => {
resolvedDecision = decision;
emitDecision(decision);
};
// --- ITEM B: build the embedFn for the L2 embedding fast-path ---
// The vector cache is persisted at the NeuroLink instance level so
// tool embedding vectors are computed once and reused across turns
// (Finding 1 fix). It is cleared by setToolRoutingServers() when the
// catalog changes so stale vectors are never used after an update.
let routingEmbedFn;
const embeddingCfg = routingConfig.embedding;
if (embeddingCfg?.enabled === true) {
try {
// Resolve the embedding provider: use the explicitly configured one
// if present, otherwise fall back to the stream/generate call's
// provider. The factory call is wrapped in try/catch so a provider
// that doesn't support embedMany (it throws at call time, not
// construction time) fails open when routingEmbedFn is invoked.
const embProviderName = embeddingCfg.provider ??
(options.provider && options.provider !== "auto"
? options.provider
: undefined) ??
routingConfig.routerModel?.provider;
if (embProviderName) {
const embProvider = await AIProviderFactory.createProvider(embProviderName, embeddingCfg.model, true, this, undefined, this.resolveCredentials(options.credentials));
// Bind embedMany with the configured model (may be undefined —
// the provider uses its default embedding model in that case).
routingEmbedFn = (texts) => withTimeout(embProvider.embedMany(texts, embeddingCfg.model), embeddingCfg.timeoutMs ?? 10000);
// Lazy-init the persistent vector cache for this instance.
// Subsequent turns reuse the same Map so text→vector lookups
// already populated from earlier turns are served from memory.
if (!this.toolRoutingVectorCache) {
this.toolRoutingVectorCache = new Map();
}
}
}
catch (embSetupError) {
logger.debug("[ToolRouting] Embedding provider setup failed, L2 path disabled for this turn", {
error: embSetupError instanceof Error
? embSetupError.message
: String(embSetupError),
});
// routingEmbedFn remains undefined — fast-path is skipped.
}
}
routedExcludeTools = await resolveToolRoutingExclusions({
catalog,
alwaysIncludeServerIds: routingConfig.alwaysIncludeServerIds ?? [],
userQuery: routingQuery,
routerPromptPrefix: routingConfig.routerPromptPrefix,
routerModel: {
provider: routingConfig.routerModel?.provider ??
options.provider,
model: routingConfig.routerModel?.model ?? options.model,
region: routingConfig.routerModel?.region ?? options.region,
temperature: routingConfig.routerModel?.temperature,
},
timeoutMs: routingConfig.timeoutMs ?? DEFAULT_TOOL_ROUTING_TIMEOUT_MS,
// Forward the abort signal so a cancelled turn aborts the router
// call promptly instead of waiting out the routing timeout.
generateFn: (generateOptions) => this.generate({
...generateOptions,
abortSignal: options.abortSignal,
}),
// Calibrated per-server routing when a decision provider is
// configured. tryDecide returns null without one, so the resolver
// falls straight through to the generative router as before.
decideFn: (decisionOptions) => this.tryDecide({
...decisionOptions,
signal: options.abortSignal,
}),
decisionMinDropConfidence: routingConfig.minDropConfidence,
emitDecision: captureDecision,
// L2 / ITEM D — only populated when embedding is configured.
embedFn: routingEmbedFn,
embeddingConfig: embeddingCfg,
granularity: routingConfig.granularity ?? "server",
// Pass the persistent vector cache so tool embeddings are reused
// across turns (Finding 1).
embeddingVectorCache: routingEmbedFn !== undefined
? this.toolRoutingVectorCache
: undefined,
});
}
finally {
this._disableToolCacheForCurrentRequest =
cacheDisabledForCurrentRequest;
}
// Aborted during the router call — skip applying now-stale exclusions;
// the main generation path enforces the abort itself.
if (options.abortSignal?.aborted) {
return;
}
// Snapshot the raw (pre-stickiness) exclusion list before applying
// stickiness overrides. The cache stores this snapshot so future cache
// hits can re-apply the then-current stickiness state (Finding 2).
const preStickinessExcludeTools = routedExcludeTools;
// Apply stickiness FIRST: the sticky ids were recorded on a prior turn and
// represent servers that should stay warm for the current turn. Consuming
// (decrementing) them before recordSelection ensures the window covers the
// correct set of future turns rather than burning one count on the same
// turn the selection is recorded (off-by-one fix).
if (stickinessEnabled && cache && sessionId) {
try {
const stickyIds = cache.getStickyServerIds(sessionId);
if (stickyIds.length > 0) {
// Remove from routedExcludeTools any tool belonging to a sticky server.
// Precompute prefixes to avoid rebuilding the set inside the predicate.
const stickyPrefixes = stickyIds.map((id) => `${id}_`);
routedExcludeTools = routedExcludeTools.filter((toolName) => !stickyPrefixes.some((prefix) => toolName.startsWith(prefix)));
}
}
catch {
// Stickiness failure is non-fatal.
}
}
// --- ITEM C: store result for future turns ---
if (resolvedDecision?.outcome === "applied" && cache) {
// Store the PRE-stickiness exclusion list in the cache so future hits
// re-apply the live stickiness window rather than the one from this
// turn (Finding 2).
if (cacheEnabled && cacheKey !== undefined) {
try {
cache.set(cacheKey, {
excludedToolNames: preStickinessExcludeTools,
selectedServerIds: resolvedDecision.selectedServerIds,
});
}
catch {
// Cache write failure is non-fatal.
}
}
// Record selected servers for stickiness on subsequent turns. This must
// come after getStickyServerIds so the window count is not consumed on
// the same turn it is set (the off-by-one fix above).
if (stickinessEnabled && sessionId) {
try {
cache.recordSelection(sessionId, resolvedDecision.selectedServerIds);
}
catch {
// Stickiness failure is non-fatal.
}
}
}
if (routedExcludeTools.length > 0) {
options.excludeTools = [
...(options.excludeTools ?? []),
...routedExcludeTools,
];
}
}
catch (error) {
if (isAbortError(error)) {
throw error;
}
logger.warn("[ToolRouting] Routing setup failed, failing open", {
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Loads a bounded window of prior conversation turns for the router so a
* follow-up turn carries the context it needs to classify intent. Reads this
* turn's conversation memory (keyed by `context.sessionId`) with
* summarization disabled to keep the router cheap. Fails open to an empty
* list — routing then falls back to the current query alone (prior
* behaviour). On the first turn of a conversation memory may not be
* initialised yet; that also yields an empty list, which is fine since the
* opening message already carries its own context.
*/
async fetchRecentRoutingHistory(options) {
try {
const requestContext = options.context;
// Inline multi-turn callers pass prior turns via options.conversationMessages
// (the same field the main model reads) rather than server-side session
// memory. Honor it directly so a contextless follow-up still routes with
// context even when no sessionId is present.
if (options.conversationMessages &&
options.conversationMessages.length > 0) {
return options.conversationMessages;
}
const sessionId = requestContext?.sessionId;
if (typeof sessionId !== "string" || !sessionId) {
return [];
}
// The pre-call router runs earlier in the stream pipeline than the main
// generation path's own memory init (initializeConversationMemoryForGeneration),
// so this.conversationMemory is still undefined at router time and the
// router would only ever see the current turn. Trigger the same lazy init
// the main path uses — it is idempotent, so the later call is a no-op —
// so the router can read prior turns. Fails open via the surrounding catch.
await this.initializeConversationMemoryForGeneration(`tool-routing-${Date.now()}`, Date.now(), process.hrtime.bigint());
const memory = this.conversationMemory;
if (!memory) {
return [];
}
// Reuse the SAME reader the main model uses so the router sees identically
// curated history: polluted turns dropped, read instrumented under the
// neurolink.conversation.getMessages span. enableSummarization=false keeps
// routing cheap and free of any summary-LLM side effect. The remaining
// tool_call/tool_result turns are dropped at transcript-render time
// (buildRoutingQueryFromHistory) to mirror what the main model is sent.
const messages = await getConversationMessages(memory, {
...options,
enableSummarization: false,
});
logger.debug("[ToolRouting] Loaded conversation history for router", {
sessionId,
messageCount: messages.length,
});
return messages;
}
catch (error) {
logger.debug("[ToolRouting] Failed to load conversation history; routing on current query only", {
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Supplies (or replaces) the pre-call tool routing server catalog.
*
* For hosts that only know their tool servers after constructing NeuroLink
* (e.g. tools are registered per session/conversation). Routing must still
* be enabled via the constructor's `toolRouting.enabled` — setting servers
* alone does not activate it.
*/
setToolRoutingServers(servers) {
if (!this.toolRoutingConfig) {
logger.warn("[ToolRouting] setToolRoutingServers called without toolRouting constructor config — servers stored but routing stays disabled");
this.toolRoutingConfig = { enabled: false, servers };
return;
}
this.toolRoutingConfig.servers = servers;
// Clear the persisted vector cache so tool vectors are recomputed against
// the new catalog on the next turn (stale vectors must never be reused).
this.toolRoutingVectorCache = undefined;
// Cached routing decisions are catalog-dependent too; force the next turn
// to recompute exclusions against the new server/tool set.
this.toolRoutingCacheInstance = undefined;
}
/** Knowledge-grounding engine health (null when it was not configured). */
getKnowledgeStatus() {
return this.knowledgeGroundingEngine?.getStatus() ?? null;
}
appendKnowledgeGroundingBlockToSystemPrompt(systemPrompt, block) {
if (typeof systemPrompt === "function") {
const originalSystemPrompt = systemPrompt;
return async (context) => {
const resolved = await resolveDynamicArgument(originalSystemPrompt, context);
return resolved.value ? `${resolved.value}\n\n${block}` : block;
};
}
return systemPrompt ? `${systemPrompt}\n\n${block}` : block;
}
/**
* Retrieve knowledge grounding for a public generate/stream call without
* mutating its options. The outer call boundary decides how to apply the
* returned context. Returns undefined when the call does not opt in,
* grounding is disabled/not applicable, or retrieval fails open.
*/
async retrieveKnowledgeGrounding(options) {
const engine = this.knowledgeGroundingEngine;
if (!engine ||
!engine.isEnabled() ||
options.useKnowledgeGrounding !== true) {
return undefined;
}
const query = options.input?.text;
if (!query) {
return undefined;
}
try {
const conversationMessages = options.conversationMessages !== undefined
? options.conversationMessages
: await this.fetchRecentRoutingHistory(options);
const recentTurns = conversationMessages
.filter((message) => message.role === "user" || message.role === "assistant")
.slice(-DEFAULT_RECENT_TURNS)
.map((message) => ({
role: message.role === "assistant"
? "assistant"
: "user",
text: typeof message.content === "string" ? message.content : "",
}));
return await engine.ground({
query,
recentTurns,
scope: options.knowledgeContext,
});
}
catch (error) {
logger.warn("[KnowledgeGrounding] grounding hook failed open", {
error: String(error),
});
return undefined;
}
}
async validateStreamRequestOptions(options, startTime) {
await this.validateStreamInput(options);
// Input validation for stream
if (options.inputValidation && options.input?.text) {
const iv = options.inputValidation;
if (iv.trimWhitespace) {
options.input.text = options.input.text.trim();
}
if (iv.requireContent && !options.input.text.trim()) {
throw new Error("Input content is required but was empty or whitespace");
}
if (iv.minLength && options.input.text.length < iv.minLength) {
throw new Error(`Input text is too short (${options.input.text.length} < ${iv.minLength})`);
}
if (iv.maxLength && options.input.text.length > iv.maxLength) {
throw new Error(`Input text is too long (${options.input.text.length} > ${iv.maxLength})`);
}
}
// PII detection and redaction for stream
if (options.piiDetection?.enabled && options.input?.text) {
const piiResult = await detectAndRedactPII(options.input.text, {
enabled: true,
action: options.piiDetection.action ?? "warn",
detectTypes: options.piiDetection.detectTypes,
customPatterns: options.piiDetection.customPatterns,
allowList: options.piiDetection.allowList,
redactionText: options.piiDetection.redactionText,
});
if (piiResult.action === "abort") {
throw new Error(piiResult.feedback ?? "Request blocked: PII detected in input");
}
options.input.text = piiResult.text;
}
this.enforceSessionBudget(options.maxBudgetUsd);
await this.applyAuthenticatedRequestContext(options);
this.emitStreamStartEvents(options, startTime);
this.applyStreamLifecycleMiddleware(options);
}
async maybeHandleWorkflowStreamRequest(params) {
if (!params.options.workflow && !params.options.workflowConfig) {
return null;
}
const result = await this.streamWithWorkflow(params.options, params.startTime);
const originalWorkflowStream = result.stream;
const self = this;
result.stream = (async function* () {
try {
for await (const chunk of originalWorkflowStream) {
yield chunk;
}
params.streamSpan.setStatus({ code: SpanStatusCode.OK });
}
catch (error) {
params.streamSpan.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
finally {
self._disableToolCacheForCurrentRequest = false;
self._toolCacheKeysServedThisRequest = new Set();
self._generationTurnActive = false;
params.streamSpan.setAttribute("neurolink.response_time_ms", Date.now() - params.spanStartTime);
params.streamSpan.end();
}
})();
return result;
}
async runStandardStreamRequest(params) {
const { options, streamSpan, spanStartTime, startTime, hrTimeStart, streamId, originalPrompt, ttsResolver, ttsMetadataSink, } = params;
logger.debug("[NeuroLink] Running standard stream request", {
streamId,
provider: options.provider,
model: options.model,
inputLength: options.input?.text?.length || 0,
disableTools: options.disableTools,
enableAnalytics: options.enableAnalytics,
enableEvaluation: options.enableEvaluation,
contextKeys: options.context ? Object.keys(options.context) : [],
optionKeys: Object.keys(options),
sessionId: options.context?.sessionId,
});
try {
const { enhancedOptions, factoryResult } = await this.prepareStreamOptions(options, streamId, startTime, hrTimeStart);
logger.debug("[NeuroLink] Stream options prepared", {
streamId,
options: enhancedOptions,
factoryResult,
sessionId: enhancedOptions.context?.sessionId,
});
// Curator P2-4 dedup (concurrency-safe): native provider stream paths
// emit `generation:end` themselves. We attach a per-stream mutable
// flag to `enhancedOptions._streamDedupContext`; the provider flips it
// before its own emit and the finally block below reads the same
// closed-over reference, so the orchestration does not emit a second
// event for the same stream. Concurrent streams have different option
// objects and therefore different contexts, so they cannot interfere.
//
// #1741: this MUST be attached before `createMCPStream`. That call
// reaches `provider.stream({ ...options })`, which copies the options
// — so a context attached afterwards never reaches the provider, and
// every native stream path silently emitted twice. The flag looked
// present on `enhancedOptions` and was simply absent from the object
// the provider actually held.
const dedupContext = {
providerEmitted: false,
};
enhancedOptions._streamDedupContext = dedupContext;
// Kept whole (not just destructured) so its toolsUsed/toolExecutions
// descriptors can be re-applied onto the final StreamResult near the
// bottom of this method, without ever reading — and thereby freezing —
// a getter-based provider's value here.
const mcpStreamOutcome = await this.createMCPStream(enhancedOptions);
const { stream: mcpStream } = mcpStreamOutcome;
const providerName = mcpStreamOutcome.provider ?? "unknown";
let streamedTTSResult;
const { stream: incrementalStream, ttsMetadata: streamTtsMetadata } = await this.createIncrementalTTSStream({
stream: mcpStream,
ttsOptions: enhancedOptions.tts,
providerName,
fallbackProvider: enhancedOptions.provider,
onComplete: (result) => {
streamedTTSResult = result;
},
});
ttsMetadataSink?.(streamTtsMetadata);
// E1b: do NOT snapshot mcpStreamOutcome.toolCalls/toolResults here.
// For a provider whose tool loop runs in the background (native Vertex
// Claude — see the `allToolCalls`-backed getter in
// googleVertex/client.ts), toolCalls/toolResults are live getters that
// are still empty at this instant: `createMCPStream` has only just
// kicked the loop off, and it fills in as the caller drains
// `incrementalStream` below. Reading the getter now and storing the
// returned VALUE freezes that empty snapshot forever, because a
// property assignment is not a live binding — previously only the
// cross-provider fallback branch (handleStreamFallback) ever
// reassigned it, so every other path returned an empty array even
// after the caller had fully drained a stream with real tool calls.
// `streamState.toolCalls`/`toolResults` now start undefined and are
// set only by that fallback branch; every read goes through
// `currentToolCalls`/`currentToolResults`, which fall through to
// `mcpStreamOutcome`'s own (possibly still-live) getter so a read
// after the stream completes sees the real, current value. Providers
// that populate toolCalls eagerly (a plain array, not a getter) are
// unaffected — `mcpStreamOutcome.toolCalls` already holds their final
// value the first time it's read.
const streamState = {
finishReason: mcpStreamOutcome.finishReason ?? "stop",
toolCalls: undefined,
toolResults: undefined,
};
const currentToolCalls = () => streamState.toolCalls ?? mcpStreamOutcome.toolCalls ?? [];
const currentToolResults = () => streamState.toolResults ?? mcpStreamOutcome.toolResults ?? [];
streamSpan.setAttribute(ATTR.NL_PROVIDER, providerName || "unknown");
let accumulatedContent = "";
let chunkCount = 0;
const { eventSequence, cleanup: cleanupListeners } = this.setupStreamEventListeners();
const metadata = {
fallbackAttempted: false,
guardrailsBlocked: false,
error: undefined,
fallbackProvider: undefined,
fallbackModel: undefined,
};
const self = this;
const streamStartTime = Date.now();
const sessionId = enhancedOptions.context
?.sessionId;
const processedStream = (async function* () {
let streamError;
// Curator P2-4: hoist `resolvedUsage` so the finally block can emit a
// single `generation:end` event with cost data. Cost listeners
// subscribe here; previously the stream path never fired it.
let resolvedUsage;
// Reviewer follow-up: track *non-sentinel output chunks* (text,
// audio, image — anything the SDK considers real output) so the
// fallback gate fires only when the stream produced nothing
// useful. Counting only text content here would have spuriously
// triggered fallback for valid audio-only (Google Live) and
// image-only streams. The sentinel is the only thing we exclude
// — that path can mask real provider failures (DNS, auth,
// retry-exhaustion) that AI SDK rejects with
// NoOutputGeneratedError, and we want fallback to fire there.
let realOutputChunks = 0;
try {
for await (const chunk of incrementalStream) {
chunkCount++;
const isNoOutputSentinel = chunk !== null &&
typeof chunk === "object" &&
"metadata" in chunk &&
chunk.metadata
?.noOutput === true;
const hasTextContent = chunk &&
"content" in chunk &&
typeof chunk.content === "string" &&
chunk.content.length > 0;
const hasMediaPayload = chunk !== null &&
typeof chunk === "object" &&
"type" in chunk &&
(chunk.type === "audio" ||
chunk.type === "tts_audio" ||
chunk.type === "image");
if (!isNoOutputSentinel && (hasTextContent || hasMediaPayload)) {
realOutputChunks++;
}
if (chunk &&
"content" in chunk &&
typeof chunk.content === "string") {
accumulatedContent += chunk.content;
self.emitter.emit("response:chunk", chunk.content);
self.emitter.emit("stream:chunk", {
type: "stream:chunk",
content: chunk.content,
metadata: {
chunkIndex: chunkCount,
totalLength: accumulatedContent.length,
...(isNoOutputSentinel && { noOutput: true }),
},
timestamp: Date.now(),
});
}
yield chunk;
}
// Reviewer follow-up: fire fallback when no *non-sentinel*
// output was produced — sentinel-only and truly empty streams
// both qualify, but media-only streams (audio/image) do not.
//
// fallbackOnMaxSteps: false exempts one no-output shape — a turn
// the provider reports as ended at the caller's own maxSteps bound
// (metadata.stopReason "step-cap", a mutable reference the native
// loops fill by drain time). That bound is the caller's budget,
// not a provider failure, and retrying it on another provider
// spends that provider's tokens to exceed a budget the caller set.
const cappedByCallerBudget = enhancedOptions.fallbackOnMaxSteps === false &&
mcpStreamOutcome.metadata?.stopReason === "step-cap";
if (realOutputChunks === 0 &&
!cappedByCallerBudget &&
!metadata.fallbackAttempted &&
!enhancedOptions.disableInternalFallback &&
currentToolCalls().length === 0 &&
currentToolResults().length === 0) {
const fallbackStream = self.handleStreamFallback(metadata, streamState, originalPrompt, enhancedOptions, providerName, (content) => {
accumulatedContent += content;
});
const { stream: incrementalFallback } = await self.createIncrementalTTSStream({
stream: fallbackStream,
ttsOptions: enhancedOptions.tts,
providerName,
fallbackProvider: enhancedOptions.provider,
ttsMetadata: streamTtsMetadata,
onComplete: (result) => {
streamedTTSResult = result;
},
});
yield* incrementalFallback;
}
ttsResolver?.(streamedTTSResult);
resolvedUsage = mcpStreamOutcome.usage;
if (!resolvedUsage && mcpStreamOutcome.analytics) {
try {
const resolved = await Promise.resolve(mcpStreamOutcome.analytics);
if (resolved?.tokenUsage) {
resolvedUsage = resolved.tokenUsage;
}
}
catch {
// non-blocking
}
}
if (resolvedUsage) {
mcpStreamOutcome.usage = resolvedUsage;
}
self.emitter.emit("stream:complete", {
type: "stream:complete",
content: accumulatedContent,
provider: metadata.fallbackProvider ?? providerName,
model: metadata.fallbackModel ??
mcpStreamOutcome.model ??
enhancedOptions.model,
finishReason: streamState.finishReason ?? "stop",
prompt: enhancedOptions.input?.text ||
enhancedOptions.prompt,
metadata: {
chunkCount,
totalLength: accumulatedContent.length,
durationMs: Date.now() - streamStartTime,
sessionId,
usage: resolvedUsage,
finishReason: streamState.finishReason ?? "stop",
...(metadata.fallbackAttempted && {
primaryProvider: providerName,
primaryModel: enhancedOptions.model,
fallback: true,
}),
},
timestamp: Date.now(),
});
}
catch (error) {
logger.debug("[NeuroLink.stream] Stream error occurred", {
error: error instanceof Error ? error.message : String(error),
name: error instanceof Error ? error.name : "UnknownError",
provider: providerName,
model: enhancedOptions.model,
chunkCount,
totalLength: accumulatedContent.length,
durationMs: Date.now() - streamStartTime,
sessionId,
});
streamError = error;
self.emitter.emit("stream:error", {
type: "stream:error",
content: error instanceof Error ? error.message : String(error),
provider: providerName,
model: enhancedOptions.model,
metadata: {
chunkCount,
totalLength: accumulatedContent.length,
durationMs: Date.now() - streamStartTime,
errorName: error instanceof Error ? error.name : "UnknownError",
sessionId,
},
timestamp: Date.now(),
});
throw error;
}
finally {
// Belt-and-braces: if TTS Mode 2 was requested but synthesis never
// ran (stream errored before reaching the TTS block, or Mode 2 path
// was skipped), resolve the audio promise to undefined so callers
// awaiting `streamResult.audio` never hang. Uses the explicit
// `ttsResolver` param (M11), not a side-channel cast.
// m4: a duplicate resolution is a silent no-op — Promise resolvers
// never throw, so no try/catch needed here.
ttsResolver?.(undefined);
logger.debug("[NeuroLink.stream] Stream finished, performing cleanup", {
provider: providerName,
model: enhancedOptions.model,
totalChunks: chunkCount,
totalLength: accumulatedContent.length,
durationMs: Date.now() - streamStartTime,
fallbackAttempted: metadata.fallbackAttempted,
guardrailsBlocked: metadata.guardrailsBlocked,
error: metadata.error,
});
// Curator P2-4: emit `generation:end` exactly once per stream so
// cost listeners receive the same contract as for `generate()`.
// The previous implementation only fired `stream:complete`, leaving
// any subscriber to `generation:end` with zero events.
//
// Dedup: native provider stream paths (Gemini 3 on Vertex / Google
// AI Studio) already emit `generation:end` themselves so Pipeline B
// (Langfuse) records a GENERATION observation. Skip our emit when
// they already fired — preserves their Pipeline B observation
// source and keeps the "exactly once" contract. Per-stream flag
// is concurrency-safe because it's scoped via AsyncLocalStorage.
if (!dedupContext.providerEmitted) {
try {
const finalProvider = metadata.fallbackProvider ?? providerName ?? "unknown";
const finalModel = metadata.fallbackModel ??
mcpStreamOutcome.model ??
enhancedOptions.model ??
"unknown";
const finalFinishReason = streamError
? "error"
: (streamState.finishReason ?? "stop");
self.emitter.emit("generation:end", {
provider: finalProvider,
model: finalModel,
responseTime: Date.now() - streamStartTime,
toolsUsed: currentToolCalls().map((t) => t.toolName),
timestamp: Date.now(),
result: {
content: accumulatedContent,
usage: resolvedUsage,
model: finalModel,
provider: finalProvider,
finishReason: finalFinishReason,
},
prompt: enhancedOptions.input?.text ||
enhancedOptions.prompt,
temperature: enhancedOptions.temperature,
maxTokens: enhancedOptions.maxTokens,
success: !streamError,
error: streamError
? streamError instanceof Error
? streamError.message
: String(streamError)
: undefined,
pipelineAHandled: true,
});
}
catch (emitError) {
logger.debug("[NeuroLink.stream] generation:end listener threw — ignored", {
error: emitError instanceof Error
? emitError.message
: String(emitError),
});
}
}
self._disableToolCacheForCurrentRequest = false;
self._toolCacheKeysServedThisRequest = new Set();
self._generationTurnActive = false;
cleanupListeners();
streamSpan.setAttribute("neurolink.response_time_ms", Date.now() - spanStartTime);
streamSpan.setAttribute(ATTR.NL_OUTPUT_LENGTH, accumulatedContent.length);
const primaryFailed = !!(metadata.error || streamError);
streamSpan.setAttribute(ATTR.GEN_AI_FINISH_REASON, primaryFailed ? "error" : "stop");
if (metadata.fallbackAttempted) {
streamSpan.setAttribute("neurolink.fallback_triggered", true);
if (metadata.fallbackProvider) {
streamSpan.setAttribute("neurolink.fallback_provider", metadata.fallbackProvider);
}
}
if (primaryFailed) {
streamSpan.setStatus({
code: SpanStatusCode.ERROR,
message: metadata.error ||
(streamError instanceof Error
? streamError.message
: String(streamError)),
});
}
else {
streamSpan.setStatus({ code: SpanStatusCode.OK });
}
streamSpan.end();
if (accumulatedContent.trim()) {
logger.info(`[NeuroLink.stream] stream() - COMPLETE SUCCESS`, {
provider: providerName,
model: enhancedOptions.model,
responseTimeMs: Date.now() - startTime,
contentLength: accumulatedContent.length,
fallback: metadata.fallbackAttempted,
});
}
await self.storeStreamConversationMemory({
enhancedOptions,
providerName,
originalPrompt,
accumulatedContent,
startTime,
eventSequence,
});
}
})();
const streamResult = await this.processStreamResult(processedStream, enhancedOptions, factoryResult);
streamResult.finishReason =
streamState.finishReason || streamResult.finishReason;
// #1819 / E1b: a top-level cross-provider fallback (handleStreamFallback,
// inside `processedStream` above) only reassigns
// `streamState.toolCalls`/`toolResults` once the caller starts draining
// the stream — strictly AFTER this point, since `processedStream` is a
// lazy async generator and the `processStreamResult` call above
// resolves without pulling a single chunk. A plain value copy here
// (`streamResult.toolCalls = streamState.toolCalls`) would freeze
// whatever streamState held at THIS instant — always undefined this
// early — because a property assignment is not a live binding. Define
// live getters instead, routed through `currentToolCalls`/
// `currentToolResults` (see their definition above) so every later
// read reflects the fallback override once set, and otherwise falls
// through live to `mcpStreamOutcome`'s own getter — which is what
// fixes a background-tool-loop provider (native Vertex Claude)
// reporting empty toolCalls: that getter is only populated by the time
// the caller has finished draining the stream, and this getter is read
// after that, not frozen before it.
Object.defineProperty(streamResult, "toolCalls", {
enumerable: true,
configurable: true,
get: () => currentToolCalls(),
});
Object.defineProperty(streamResult, "toolResults", {
enumerable: true,
configurable: true,
get: () => currentToolResults(),
});
if (!streamResult.usage) {
streamResult.usage = mcpStreamOutcome.usage;
}
if (!streamResult.analytics) {
// CRITICAL: do NOT `await` a Promise-typed analytics here. Bedrock
// resolves its analytics from the stream's `finally` block — it
// only completes once the consumer has fully iterated the stream.
// Awaiting it before returning the StreamResult deadlocks: caller
// can't iterate (no stream yet), analytics can't resolve (no
// iteration). Pass the Promise through to the consumer instead;
// they (or the cost listener below) can await it after consumption.
//
// The outer StreamResult type (StreamResult.analytics in
// src/lib/types/stream.ts) explicitly allows `AnalyticsData |
// Promise<AnalyticsData>` — the cast widens the local
// `processStreamResult` return type which only declares the
// resolved shape.
streamResult.analytics = mcpStreamOutcome.analytics;
}
// The cost listener wants a resolved cost, but if the provider gave
// us a Promise (Bedrock) we can't tax the stream's return path on it.
// Defer cost accumulation: resolve in the background once the analytics
// Promise settles. Synchronous analytics (most providers) still fire
// immediately because Promise.resolve(non-promise) is sync-ish.
Promise.resolve(streamResult.analytics)
.then((analytics) => {
if (analytics?.cost && analytics.cost > 0) {
this._sessionCostUsd += analytics.cost;
}
})
.catch(() => {
/* analytics rejection is non-fatal — cost stays unupdated */
});
this.emitStreamEndEvents(streamResult);
return this.createStreamResponse(streamResult, processedStream, {
providerName,
options,
startTime,
responseTime: Date.now() - startTime,
streamId,
fallback: metadata.fallbackAttempted,
guardrailsBlocked: metadata.guardrailsBlocked,
error: metadata.error,
events: eventSequence,
sourceResult: mcpStreamOutcome,
});
}
catch (error) {
ttsResolver?.(undefined);
if (options.disableInternalFallback) {
throw error;
}
return this.handleStreamError(error, options, startTime, streamId, undefined, undefined);
}
}
/** Wrap one provider stream with incremental TTS synthesis. */
async createIncrementalTTSStream(params) {
const { stream, ttsOptions, providerName, fallbackProvider, onComplete } = params;
if (!ttsOptions?.enabled) {
onComplete(undefined);
return { stream };
}
const { TTSProcessor } = await import("./utils/ttsProcessor.js");
const concreteFallback = fallbackProvider === "auto" ? undefined : fallbackProvider;
const candidate = ttsOptions.provider ?? concreteFallback ?? providerName;
const ttsProvider = candidate && TTSProcessor.supports(candidate) ? candidate : undefined;
const ttsMetadata = params.ttsMetadata ?? {
attempted: ttsProvider !== undefined,
success: false,
};
ttsMetadata.attempted = ttsProvider !== undefined;
ttsMetadata.success = false;
delete ttsMetadata.error;
delete ttsMetadata.latency;
const ttsStartedAt = Date.now();
let completionRecorded = false;
const recordCompletion = (result, error) => {
if (completionRecorded) {
return;
}
completionRecorded = true;
ttsMetadata.success = error === undefined && result !== undefined;
if (error) {
ttsMetadata.error = error;
}
else {
delete ttsMetadata.error;
}
ttsMetadata.latency = Date.now() - ttsStartedAt;
onComplete(result);
};
if (!ttsProvider) {
logger.warn(`[NeuroLink.stream] No TTS provider resolved for incremental streaming (set tts.provider explicitly — chat provider "${candidate ?? "<unset>"}" is not a registered TTS handler)`);
recordCompletion(undefined);
return { stream, ttsMetadata };
}
return {
stream: interleaveTTSStream({
stream,
provider: ttsProvider,
options: ttsOptions,
onComplete: recordCompletion,
}),
ttsMetadata,
};
}
/** Prevent provider fallback streams from duplicating outer streaming TTS. */
deferProviderStreamTTS(options) {
if (options.tts?.enabled) {
return { ...options, tts: undefined };
}
return options;
}
/**
* Prepare stream options: initialize memory, MCP, retrieval, orchestration,
* Ollama tool auto-disable, factory processing, and tool detection.
*/
async prepareStreamOptions(options, streamId, startTime, hrTimeStart) {
// Initialize conversation memory if needed (for lazy loading)
await this.initializeConversationMemoryForGeneration(streamId, startTime, hrTimeStart);
// Initialize MCP
await this.initializeMCP();
// Memory retrieval
if (this.shouldReadMemory(options.memory, options.context?.userId) &&
options.context?.userId) {
try {
options.input.text = await this.retrieveMemory(options.input.text ?? "", options.context.userId, options.memory?.additionalUsers);
logger.debug("Memory retrieval successful");
}
catch (error) {
logger.warn("Memory retrieval failed:", error);
}
}
// Skills: surface the discovery listing and inject the per-call
// use_skill / read_skill_resource tools (bodies load on activation).
await this.applySkillsAugmentation(options);
// Apply orchestration if enabled and no specific provider/model requested
if (this.enableOrchestration && !options.provider && !options.model) {
try {
const orchestratedOptions = await this.applyStreamOrchestration(options);
logger.debug("Stream orchestration applied", {
originalProvider: options.provider || "auto",
orchestratedProvider: orchestratedOptions.provider,
orchestratedModel: orchestratedOptions.model,
prompt: options.input.text?.substring(0, 100),
});
// Use orchestrated options — rebind the local `options` to a fresh
// merged object instead of mutating the caller-supplied one
// (NEW2: avoids cross-call contamination when callers reuse options).
// Issue 6: extract to an explicit local so the rebind intent is
// obvious to future readers, and the lint suppression is scoped
// narrowly to the one statement that actually rebinds the param.
const mergedOptions = { ...options, ...orchestratedOptions };
options = mergedOptions;
// Re-resolve model alias in case orchestration returned an alias
if (orchestratedOptions.model) {
options.model = resolveModel(options.model, this.modelAliasConfig);
}
}
catch (error) {
logger.warn("Stream orchestration failed, continuing with original options", {
error: error instanceof Error ? error.message : String(error),
originalProvider: options.provider || "auto",
});
// Continue with original options if orchestration fails
}
}
// Auto-disable tools for Ollama models that don't support them
await this.autoDisableOllamaStreamTools(options);
// RAG Integration: If rag config is provided, prepare the RAG search tool
if (options.rag?.files?.length) {
try {
const { prepareRAGTool } = await import("./rag/ragIntegration.js");
const ragResult = await prepareRAGTool(options.rag, options.provider);
// Inject the RAG tool into the tools record
if (!options.tools) {
options.tools = {};
}
options.tools[ragResult.toolName] =
ragResult.tool;
// Inject RAG-aware system prompt so the AI uses the RAG tool first
const ragSystemInstruction = [
`\n\nIMPORTANT: You have a tool called "${ragResult.toolName}" that searches through`,
`${ragResult.filesLoaded} loaded document(s) containing ${ragResult.chunksIndexed} indexed chunks.`,
`ALWAYS use the "${ragResult.toolName}" tool FIRST to answer the user's question before using any other tools.`,
`This tool searches your local knowledge base of pre-loaded documents and is the primary source of truth.`,
`Do NOT use websearchGrounding or any web search tools when the answer can be found in the loaded documents.`,
].join(" ");
options.systemPrompt =
(options.systemPrompt || "") + ragSystemInstruction;
logger.info("[RAG] Tool injected into stream()", {
toolName: ragResult.toolName,
filesLoaded: ragResult.filesLoaded,
chunksIndexed: ragResult.chunksIndexed,
});
}
catch (error) {
logger.warn("[RAG] Failed to prepare RAG tool, continuing without RAG", {
error: error instanceof Error ? error.message : String(error),
});
}
}
const factoryResult = processStreamingFactoryOptions(options);
const enhancedOptions = createCleanStreamOptions(options);
if (options.input?.text) {
const { toolResults: _toolResults, enhancedPrompt } = await this.detectAndExecuteTools(options.input.text, undefined);
if (enhancedPrompt !== options.input.text) {
enhancedOptions.input.text = enhancedPrompt;
}
}
return { enhancedOptions, factoryResult };
}
/**
* Auto-disable tools for Ollama models that don't support them (stream mode).
* Prevents overwhelming smaller models with massive tool descriptions in the system message.
*/
async autoDisableOllamaStreamTools(options) {
if ((options.provider === "ollama" ||
options.provider?.toLowerCase().includes("ollama")) &&
!options.disableTools) {
const { ModelConfigurationManager } = await import("./core/modelConfiguration.js");
const modelConfig = ModelConfigurationManager.getInstance();
const ollamaConfig = modelConfig.getProviderConfiguration("ollama");
const toolCapableModels = ollamaConfig?.modelBehavior?.toolCapableModels || [];
// Only disable tools if we have explicit evidence the model doesn't support them
// If toolCapableModels is empty or model is not specified, don't make assumptions
const modelName = options.model;
if (toolCapableModels.length > 0 && modelName) {
const modelSupportsTools = toolCapableModels.some((capableModel) => modelName.toLowerCase().includes(capableModel.toLowerCase()));
if (!modelSupportsTools) {
options.disableTools = true;
logger.debug("Auto-disabled tools for Ollama model that doesn't support them (stream)", {
model: options.model,
toolCapableModels: toolCapableModels.slice(0, 3), // Show first 3 for brevity
});
}
}
}
}
/**
* Set up event listeners for stream event capture (tool calls, HITL, UI components).
* Returns the shared event sequence array and a cleanup function to remove all listeners.
*/
setupStreamEventListeners() {
const eventSequence = [];
let eventSeqCounter = 0;
const captureEvent = (type, data) => {
eventSequence.push({
type,
seq: eventSeqCounter++,
timestamp: Date.now(),
...(data && typeof data === "object" ? data : { data }),
});
};
const onResponseChunk = (...args) => {
const chunk = args[0];
captureEvent("response:chunk", { content: chunk });
};
const onToolStart = (...args) => {
const data = args[0];
captureEvent("tool:start", {
...data,
toolName: data.toolName ?? data.tool,
});
};
const onToolEnd = (...args) => {
const data = args[0];
const toolName = data.toolName ?? data.tool;
const responseTime = data.responseTime ?? data.duration;
const success = data.success ?? (data.error !== undefined ? false : undefined);
const augmented = {
...data,
toolName,
...(responseTime !== undefined ? { responseTime } : {}),
...(success !== undefined ? { success } : {}),
...(data.error !== undefined ? { error: data.error } : {}),
};
captureEvent("tool:end", augmented);
if (augmented.result && augmented.result.uiComponent === true) {
captureEvent("ui-component", {
toolName,
componentData: augmented.result,
timestamp: Date.now(),
...(success !== undefined ? { success } : {}),
...(responseTime !== undefined ? { responseTime } : {}),
});
}
};
const onUIComponent = (...args) => {
captureEvent("ui-component", args[0]);
};
const onHITLRequest = (...args) => {
captureEvent("hitl:confirmation-request", args[0]);
};
const onHITLResponse = (...args) => {
captureEvent("hitl:confirmation-response", args[0]);
};
this.emitter.on("response:chunk", onResponseChunk);
this.emitter.on("tool:start", onToolStart);
this.emitter.on("tool:end", onToolEnd);
this.emitter.on("ui-component", onUIComponent);
this.emitter.on("hitl:confirmation-request", onHITLRequest);
this.emitter.on("hitl:confirmation-response", onHITLResponse);
const cleanup = () => {
this.emitter.off("response:chunk", onResponseChunk);
this.emitter.off("tool:start", onToolStart);
this.emitter.off("tool:end", onToolEnd);
this.emitter.off("ui-component", onUIComponent);
this.emitter.off("hitl:confirmation-request", onHITLRequest);
this.emitter.off("hitl:confirmation-response", onHITLResponse);
};
return { eventSequence, cleanup };
}
/**
* Handle fallback when the primary stream returns 0 chunks.
* Yields chunks from a fallback provider and updates metadata accordingly.
*/
async *handleStreamFallback(metadata, streamState, originalPrompt, enhancedOptions, providerName, appendContent) {
metadata.fallbackAttempted = true;
const errorMsg = "Stream completed with 0 chunks (possible guardrails block)";
metadata.error = errorMsg;
// Record a failed-provider span for the primary provider that returned 0 chunks
try {
const traceCtx = this._metricsTraceContext;
let failedSpan = SpanSerializer.createGenerationSpan({
provider: providerName,
model: enhancedOptions.model || "unknown",
name: `gen_ai.${providerName}.stream.failed`,
traceId: traceCtx?.traceId,
parentSpanId: traceCtx?.parentSpanId,
});
failedSpan = SpanSerializer.endSpan(failedSpan, SpanStatus.ERROR);
failedSpan.statusMessage = errorMsg;
failedSpan.durationMs = 0;
this.metricsAggregator.recordSpan(failedSpan);
getMetricsAggregator().recordSpan(failedSpan);
}
catch {
/* non-blocking */
}
// BZ-1341: Support fallback provider override via options or env vars
const optFallbackProvider = enhancedOptions.fallbackProvider?.trim() || undefined;
const optFallbackModel = enhancedOptions.fallbackModel?.trim() || undefined;
const envFallbackProvider = process.env.FALLBACK_PROVIDER?.trim() || undefined;
const envFallbackModel = process.env.FALLBACK_MODEL?.trim() || undefined;
const modelConfigRoute = ModelRouter.getFallbackRoute(originalPrompt || enhancedOptions.input.text || "", {
provider: providerName,
model: enhancedOptions.model || "gpt-4o",
reasoning: "primary failed",
confidence: 0.5,
}, { fallbackStrategy: "auto" });
const fallbackRoute = {
...modelConfigRoute,
provider: optFallbackProvider ?? envFallbackProvider ?? modelConfigRoute.provider,
model: optFallbackModel ?? envFallbackModel ?? modelConfigRoute.model,
};
logger.warn("Retrying with fallback provider", {
originalProvider: providerName,
fallbackProvider: fallbackRoute.provider,
fallbackModel: fallbackRoute.model,
fallbackSource: optFallbackProvider || optFallbackModel
? "options"
: envFallbackProvider || envFallbackModel
? "env"
: "model_config",
reason: errorMsg,
});
try {
const fallbackProvider = await AIProviderFactory.createProvider(fallbackRoute.provider, fallbackRoute.model, true, undefined, undefined, this.resolveCredentials(enhancedOptions.credentials));
// Ensure fallback provider can execute tools
fallbackProvider.setupToolExecutor({
customTools: this.getCustomTools(),
executeTool: (toolName, params) => this.executeTool(toolName, params, {
disableToolCache: enhancedOptions.disableToolCache,
}),
}, "NeuroLink.fallbackStream");
// Prefer the caller-supplied / compacted conversation messages that were
// threaded through options. Only fall back to memory when no explicit
// history was provided — this preserves caller-supplied empty arrays
// (which signal "no prior context") and avoids resurrecting stale memory.
const conversationMessages = enhancedOptions.conversationMessages !== undefined
? enhancedOptions.conversationMessages
: await getConversationMessages(this.conversationMemory, {
prompt: enhancedOptions.input.text,
context: enhancedOptions.context,
});
fallbackProvider.setFileToolRootPolicy?.(this.fileToolRootsFor(enhancedOptions));
const fallbackResult = await fallbackProvider.stream({
...this.deferProviderStreamTTS(enhancedOptions),
model: fallbackRoute.model,
conversationMessages,
});
const fallbackToolCalls = fallbackResult.toolCalls ?? [];
const fallbackToolResults = fallbackResult.toolResults ?? [];
if (fallbackToolCalls.length > 0 || fallbackToolResults.length > 0) {
streamState.toolCalls = fallbackToolCalls;
streamState.toolResults = fallbackToolResults;
streamState.finishReason =
fallbackResult.finishReason ?? streamState.finishReason;
}
// Reviewer follow-up: count *real* output chunks for the fallback
// success gate, mirroring the primary stream wrapper. A fallback
// that yields only the NoOutputSentinel must not be treated as
// success — that's the same masked-failure scenario as the primary.
let fallbackChunkCount = 0;
let fallbackRealOutputChunks = 0;
for await (const fallbackChunk of fallbackResult.stream) {
fallbackChunkCount++;
const isFallbackNoOutputSentinel = fallbackChunk !== null &&
typeof fallbackChunk === "object" &&
"metadata" in fallbackChunk &&
fallbackChunk.metadata
?.noOutput === true;
const fallbackHasTextContent = fallbackChunk &&
"content" in fallbackChunk &&
typeof fallbackChunk.content === "string" &&
fallbackChunk.content.length > 0;
const fallbackHasMediaPayload = fallbackChunk !== null &&
typeof fallbackChunk === "object" &&
"type" in fallbackChunk &&
(fallbackChunk.type === "audio" ||
fallbackChunk.type === "tts_audio" ||
fallbackChunk.type === "image");
if (!isFallbackNoOutputSentinel &&
(fallbackHasTextContent || fallbackHasMediaPayload)) {
fallbackRealOutputChunks++;
}
if (fallbackChunk &&
"content" in fallbackChunk &&
typeof fallbackChunk.content === "string") {
appendContent(fallbackChunk.content);
this.emitter.emit("response:chunk", fallbackChunk.content);
}
yield fallbackChunk;
}
// Read after the drain: a background-loop provider (native Vertex+Claude)
// fills these while the stream is consumed, so the values taken before
// the loop are still empty for it. Without this, a fallback that ran
// tools reports none on the result and in the end-of-turn events, and
// the 0-output gate below counts tool calls that had not appeared yet.
const finalToolCalls = fallbackResult.toolCalls ?? [];
const finalToolResults = fallbackResult.toolResults ?? [];
if (fallbackRealOutputChunks === 0 &&
finalToolCalls.length === 0 &&
finalToolResults.length === 0) {
throw new Error(`Fallback provider ${fallbackRoute.provider} also returned 0 real output chunks (chunkCount=${fallbackChunkCount}, sentinel-only or empty)`);
}
streamState.toolCalls = finalToolCalls;
streamState.toolResults = finalToolResults;
streamState.finishReason =
fallbackResult.finishReason ?? streamState.finishReason;
// Fallback succeeded - likely guardrails blocked primary
metadata.fallbackProvider = fallbackRoute.provider;
metadata.fallbackModel = fallbackRoute.model;
metadata.guardrailsBlocked = true;
}
catch (fallbackError) {
const fallbackErrorMsg = fallbackError instanceof Error
? fallbackError.message
: String(fallbackError);
metadata.error = `${errorMsg}; Fallback failed: ${fallbackErrorMsg}`;
logger.error("Fallback provider failed", {
fallbackProvider: fallbackRoute.provider,
error: fallbackErrorMsg,
});
throw fallbackError;
}
}
/**
* Store conversation memory after stream consumption is complete (called from finally block).
* Handles conversation memory storage in the background.
*/
async storeStreamConversationMemory(params) {
const { enhancedOptions, providerName, originalPrompt, accumulatedContent, startTime, eventSequence, } = params;
// Logger Guard: the full options object (history + tool outputs) can be
// enormous — an eager JSON.stringify here threw RangeError: Invalid
// string length in production and killed the turn. Serialize lazily,
// bounded, and only when debug is actually enabled; sanitizeRecord
// redacts credentials/PII keys before anything reaches the sink.
if (logger.shouldLog("debug")) {
logger.debug("[NeuroLink.stream] Preparing to store conversation turn in memory", {
options: safeDebugSerialize(sanitizeRecord(enhancedOptions)),
sessionId: enhancedOptions.context
?.sessionId,
});
}
// Guard: skip storing if no meaningful content was produced (no text AND no tool activity)
const hasToolEvents = eventSequence.some((e) => e.type === "tool:start" || e.type === "tool:end");
if (!accumulatedContent.trim() && !hasToolEvents) {
logger.warn("[NeuroLink.stream] Skipping conversation turn storage — no text content or tool activity", {
sessionId: enhancedOptions.context
?.sessionId,
});
return;
}
// Logger Guard: see the matching block above — never eagerly stringify
// the full options object, and always redact before serializing.
if (logger.shouldLog("debug")) {
logger.debug("[NeuroLink.stream] Storing conversation turn in memory", {
options: safeDebugSerialize(sanitizeRecord(enhancedOptions)),
sessionId: enhancedOptions.context
?.sessionId,
conversationMemoryExists: this.conversationMemory ? true : false,
});
}
// Store memory after stream consumption is complete
if (this.conversationMemory && enhancedOptions.context?.sessionId) {
const sessionId = enhancedOptions.context
?.sessionId;
const userId = enhancedOptions.context
?.userId;
let providerDetails;
if (enhancedOptions.model) {
providerDetails = {
provider: providerName,
model: enhancedOptions.model,
};
}
const memStoreStart = Date.now();
try {
const pendingSkillMessages = this.drainPendingSkillMessages(sessionId);
await this.conversationMemory.storeConversationTurn({
sessionId,
userId,
userMessage: originalPrompt ?? "",
aiResponse: accumulatedContent,
startTimeStamp: new Date(startTime),
providerDetails,
enableSummarization: enhancedOptions.enableSummarization,
events: eventSequence.length > 0 ? eventSequence : undefined,
requestId: enhancedOptions.context
?.requestId,
...(pendingSkillMessages.length > 0
? { skillMessages: pendingSkillMessages }
: {}),
});
this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "stream" }, Date.now() - memStoreStart, SpanStatus.OK);
logger.debug("[NeuroLink.stream] Stored conversation turn with events", {
sessionId,
eventCount: eventSequence.length,
eventTypes: [...new Set(eventSequence.map((e) => e.type))],
});
}
catch (error) {
this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "stream" }, Date.now() - memStoreStart, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
logger.warn("Failed to store stream conversation turn", {
error: error instanceof Error ? error.message : String(error),
});
}
}
if (this.shouldWriteMemory(enhancedOptions.memory, enhancedOptions.context?.userId, accumulatedContent)) {
this.storeMemoryInBackground(originalPrompt ?? "", accumulatedContent.trim(), enhancedOptions.context?.userId, enhancedOptions.memory?.additionalUsers, enhancedOptions.context);
}
}
/**
* Validate stream input with comprehensive error reporting
*/
async validateStreamInput(options) {
const validationStartTime = process.hrtime.bigint();
logger.debug(`[NeuroLink] 🎯 LOG_POINT_003_VALIDATION_START`, {
logPoint: "003_VALIDATION_START",
validationStartTimeNs: validationStartTime.toString(),
message: "Starting comprehensive input validation process",
});
const hasText = typeof options?.input?.text === "string" &&
options.input.text.trim().length > 0;
// Accept audio when frames are present; sampleRateHz is optional (defaults applied later)
const hasAudio = !!(options?.input?.audio &&
options.input.audio.frames &&
typeof options.input.audio.frames[Symbol.asyncIterator] === "function");
// STT pre-recorded audio buffer counts as input — transcription will fill text.
const hasSttAudio = !!(options?.stt?.enabled && options?.stt?.audio);
if (!hasText && !hasAudio && !hasSttAudio) {
throw new Error("Stream options must include either input.text, input.audio, or stt.audio");
}
}
/**
* Emit stream start events
*/
emitStreamStartEvents(options, startTime) {
this.emitter.emit("stream:start", {
provider: options.provider || "auto",
timestamp: startTime,
});
this.emitter.emit("response:start");
this.emitter.emit("message", `Starting ${options.provider || "auto"} stream...`);
}
/**
* Create MCP stream
*/
async createMCPStream(options) {
// Simplified placeholder - in the actual implementation this would contain the complex MCP stream logic
const providerName = await getBestProvider(options.provider);
const provider = await AIProviderFactory.createProvider(providerName, options.model, !options.disableTools, // Pass disableTools as inverse of enableMCP
this, // Pass SDK instance
options.region, // Pass region parameter
this.resolveCredentials(options.credentials));
// Runtime model-limit discovery must land BEFORE the stream budget
// check below — otherwise it computes against the static default
// window. Never rejects; failure degrades to static defaults.
await provider.ensureModelLimits?.();
// Propagate trace context for parent-child span hierarchy
provider.setTraceContext(this._metricsTraceContext);
// Enable tool execution for the provider using BaseProvider method
provider.setupToolExecutor({
customTools: this.getCustomTools(),
executeTool: (toolName, params) => this.executeTool(toolName, params, {
disableToolCache: options.disableToolCache,
}),
}, "NeuroLink.createMCPStream");
// 🔧 FIX: Get available tools and create tool-aware system prompt
// Use SAME pattern as tryMCPGeneration (generate mode)
let availableTools = await this.getAllAvailableTools();
// Apply per-call tool filtering for system prompt tool descriptions
availableTools = this.applyToolInfoFiltering(availableTools, options);
// M3: a provider whose descriptor declares toolSupport "none" can never
// call a tool in ANY form — not natively, not via prompt injection — so
// `availableTools` here is dead weight, not a signal. Left non-empty, it
// still drives createToolAwareSystemPrompt()'s prompt-injection branch
// (that branch triggers on `!provider.supportsTools()`, which is also
// true for these providers) and inflates the stream budget check below
// via `toolDefinitions`, so an image-generation-only provider like
// Recraft can fail a fabricated "exceeds model budget" check before its
// own request-kind routing (BaseProvider.stream() -> resolveRequestKind)
// ever runs. Clearing it here restores the provider's real outcome —
// its own refusal, a genuine vendor error, or success — as what
// surfaces, without touching the budget check for any text provider.
if (ProviderFactory.getDescriptor(providerName)?.toolSupport === "none") {
availableTools = [];
}
// Skip tool prompt injection if skipToolPromptInjection is true.
// For providers with native tool calling (instance truth via
// supportsTools()), the listing is skipped and only the short damping
// line is appended — full definitions ship natively via `tools`.
const enhancedSystemPrompt = options.skipToolPromptInjection
? options.systemPrompt || ""
: this.createToolAwareSystemPrompt(options.systemPrompt, availableTools, provider.supportsTools?.() ?? true);
// Get conversation messages for context.
// If the caller already supplied conversationMessages (e.g. proxy routes
// forwarding a multi-turn Claude request), honour them — including an
// explicit empty array, which signals "no prior context". Otherwise fall
// back to the conversation memory store (interactive CLI / SDK sessions).
const hasCallerConversationHistory = options.conversationMessages !== undefined;
const resolvedConversationMessages = hasCallerConversationHistory
? options.conversationMessages
: await getConversationMessages(this.conversationMemory, {
...options,
prompt: options.input.text,
context: options.context,
});
// Make the resolved messages the single source of truth so downstream
// consumers (compaction, fallback streams) reuse them instead of
// reloading from conversationMemory.
options.conversationMessages = resolvedConversationMessages;
let conversationMessages = resolvedConversationMessages;
// Pre-generation budget check for streaming
const streamBudget = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: enhancedSystemPrompt,
conversationMessages: conversationMessages,
currentPrompt: options.input.text,
toolDefinitions: availableTools,
compactionThreshold: options.compactionThreshold,
});
const streamMessageCount = conversationMessages?.length || 0;
const streamCompactionSessionId = this.getCompactionSessionId(options);
// Reviewer follow-up: gate the hard cap on the *actual compactable
// history* rather than `this.conversationMemory`. A configured-but-
// empty memory store leaves nothing to compact yet still satisfies
// `!this.conversationMemory === false`, so the previous check
// skipped the hard cap and dispatched the oversized payload.
const streamHasCompactableMessages = streamMessageCount > 0;
// Curator P1-2: pre-dispatch hard cap mirrors directProviderGeneration.
// When the budget check fails AND there's nothing to compact (no memory
// + no inline messages — only prompt + tools), throw before dispatch
// instead of wasting a roundtrip on a payload the provider will reject.
if (!streamBudget.withinBudget && !streamHasCompactableMessages) {
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: ["pre-dispatch hard cap"],
finalTokens: streamBudget.estimatedInputTokens,
budget: streamBudget.availableInputTokens,
provider: providerName,
model: options.model,
phase: "pre-dispatch-no-recovery",
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
throw new ContextBudgetExceededError(`Stream context exceeds model budget and no compaction is possible ` +
`(no conversationMemory, no inline conversationMessages — only ` +
`prompt + tools). Estimated: ${streamBudget.estimatedInputTokens} ` +
`tokens, budget: ${streamBudget.availableInputTokens} tokens. ` +
`Reduce prompt or tool-definition size, or trim the request.`, {
estimatedTokens: streamBudget.estimatedInputTokens,
availableTokens: streamBudget.availableInputTokens,
stagesUsed: [],
breakdown: streamBudget.breakdown,
});
}
if (streamBudget.shouldCompact &&
(hasCallerConversationHistory || this.conversationMemory) &&
streamMessageCount >
(this.lastCompactionMessageCount.get(streamCompactionSessionId) ?? 0)) {
const compactor = new ContextCompactor({
provider: providerName,
summarizationProvider: this.conversationMemoryConfig?.conversationMemory
?.summarizationProvider,
summarizationModel: this.conversationMemoryConfig?.conversationMemory
?.summarizationModel,
}, this.contextCompactorDeps());
const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(streamBudget, options.compactionThreshold), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId, { currentRequest: options.input.text });
if (compactionResult.compacted) {
const repairedResult = repairToolPairs(compactionResult.messages);
conversationMessages = repairedResult.messages;
// Keep options.conversationMessages in sync so downstream consumers
// (e.g. handleStreamFallback) use the compacted history rather than
// re-reading from conversationMemory.
options.conversationMessages = conversationMessages;
this.lastCompactionMessageCount.set(streamCompactionSessionId, conversationMessages.length);
}
// POST-COMPACTION BUDGET RE-CHECK (mirrors tryMCPGeneration / directProviderGeneration)
const postCompactBudget = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: enhancedSystemPrompt,
conversationMessages: conversationMessages,
currentPrompt: options.input.text,
toolDefinitions: availableTools,
});
if (!postCompactBudget.withinBudget) {
logger.warn("[NeuroLink] Stream: post-compaction still over budget, emergency truncation", {
estimatedTokens: postCompactBudget.estimatedInputTokens,
availableTokens: postCompactBudget.availableInputTokens,
overagePercent: Math.round((postCompactBudget.usageRatio - 1.0) * 100),
});
// Curator P1-2: emit `compaction.insufficient` whenever a single
// round of compaction wasn't enough — even when emergency
// truncation will save the day. Lets cost / audit listeners track
// the "compaction was insufficient" signal separately from the
// eventual outcome.
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: compactionResult.stagesUsed,
finalTokens: postCompactBudget.estimatedInputTokens,
budget: postCompactBudget.availableInputTokens,
provider: providerName,
model: options.model,
phase: "mid-compaction",
willEmergencyTruncate: true,
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
conversationMessages = emergencyContentTruncation(conversationMessages, postCompactBudget.availableInputTokens, postCompactBudget.breakdown, providerName);
// Keep options in sync after emergency truncation so fallback paths
// use the truncated history.
options.conversationMessages = conversationMessages;
const finalBudget = checkContextBudget({
provider: providerName,
model: options.model,
maxTokens: options.maxTokens,
systemPrompt: enhancedSystemPrompt,
conversationMessages: conversationMessages,
currentPrompt: options.input.text,
toolDefinitions: availableTools,
});
if (!finalBudget.withinBudget) {
// Clear watermark so handleContextOverflow recovery can re-compact
this.lastCompactionMessageCount.delete(streamCompactionSessionId);
// Curator P1-2: emit `compaction.insufficient` on the terminal
// failure path so cost / audit listeners can record the specific
// failure mode (compaction + emergency truncation both insufficient).
try {
this.emitter.emit("compaction.insufficient", {
stagesAttempted: compactionResult.stagesUsed,
finalTokens: finalBudget.estimatedInputTokens,
budget: finalBudget.availableInputTokens,
provider: providerName,
model: options.model,
phase: "post-emergency-truncation",
timestamp: Date.now(),
});
}
catch {
/* listener errors are non-fatal */
}
throw new ContextBudgetExceededError(`Stream context exceeds model budget after all compaction stages. ` +
`Estimated: ${finalBudget.estimatedInputTokens} tokens, ` +
`Budget: ${finalBudget.availableInputTokens} tokens.`, {
estimatedTokens: finalBudget.estimatedInputTokens,
availableTokens: finalBudget.availableInputTokens,
stagesUsed: compactionResult.stagesUsed,
breakdown: finalBudget.breakdown,
});
}
}
}
// ─── ModelPool stream path ────────────────────────────────────────────────
// When a pool is configured, replaces the single provider.stream() call
// with a retry loop over pool members. Compaction/budget-check above ran
// once on the initially-selected `providerName`; the pool may redirect to
// a different provider, but those checks are conservative enough that the
// redirected provider will also accept the payload (if it has a smaller
// window, the budget check would have already thrown before we get here).
// On exhaustion, throws the last member error — does NOT fall through to
// the static provider path below.
if (this.modelPool) {
const streamPool = this.modelPool;
const maxStreamAttempts = streamPool.maxAttempts;
const streamTriedKeys = new Set();
let streamLastError = null;
for (let attempt = 0; attempt < maxStreamAttempts; attempt++) {
if (options.abortSignal?.aborted) {
throw new DOMException("The operation was aborted", "AbortError");
}
const streamMember = streamPool.selectNext(streamTriedKeys);
if (!streamMember) {
break;
}
streamTriedKeys.add(streamPool.memberKey(streamMember));
// Override provider/model/region from the pool member.
// When the member omits model/region, use undefined (provider default)
// rather than inheriting the caller's value for a different provider.
const poolStreamProviderName = streamMember.provider;
const poolStreamModel = streamMember.model ?? undefined;
const poolStreamRegion = streamMember.region ?? undefined;
logger.debug(`[createMCPStream] ModelPool: attempting member ${poolStreamProviderName}`, { model: poolStreamModel, attempt });
try {
const poolStreamProvider = await AIProviderFactory.createProvider(poolStreamProviderName, poolStreamModel, !options.disableTools, this, poolStreamRegion, this.resolveCredentials(options.credentials));
poolStreamProvider.setTraceContext(this._metricsTraceContext);
poolStreamProvider.setupToolExecutor({
customTools: this.getCustomTools(),
executeTool: (toolName, params) => this.executeTool(toolName, params, {
disableToolCache: options.disableToolCache,
}),
}, "NeuroLink.createMCPStream");
poolStreamProvider.setFileToolRootPolicy?.(this.fileToolRootsFor(options));
const poolStreamResult = await poolStreamProvider.stream({
...this.deferProviderStreamTTS(options),
provider: poolStreamProviderName,
model: poolStreamModel,
region: poolStreamRegion,
systemPrompt: enhancedSystemPrompt,
conversationMessages,
});
logger.debug("[createMCPStream] ModelPool stream handle obtained", {
provider: poolStreamProviderName,
});
// Wrap the stream so we can record success/failure based on what
// actually happens during consumption, not merely on obtaining the
// handle. Recording success at handle-acquisition time (before any
// tokens are delivered) would mean a mid-stream provider drop is
// never reflected as a cooldown.
const capturedMember = streamMember;
const wrappedStream = (async function* () {
try {
yield* poolStreamResult.stream;
streamPool.recordSuccess(capturedMember);
}
catch (streamConsumeErr) {
streamPool.recordFailure(capturedMember, classifyProviderError(streamConsumeErr));
throw streamConsumeErr;
}
})();
return preserveLiveStreamAccessors(poolStreamResult, {
...poolStreamResult,
stream: wrappedStream,
provider: poolStreamProviderName,
model: poolStreamResult.model || poolStreamModel,
toolCalls: poolStreamResult.toolCalls ?? [],
toolResults: poolStreamResult.toolResults ?? [],
});
}
catch (poolStreamError) {
if (isAbortError(poolStreamError)) {
throw poolStreamError;
}
if (isNonRetryableForPool(poolStreamError)) {
const poolStreamErrMsg = poolStreamError instanceof Error
? poolStreamError.message
: String(poolStreamError);
streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
// Wrap in a plain Error so runWithFallbackOrchestration /
// streamWithIterationFallback does not re-activate the modelChain
// layer on top of an already-exhausted pool.
throw new Error(`[ModelPool] non-retryable: ${poolStreamErrMsg}`, {
cause: poolStreamError,
});
}
streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
streamLastError =
poolStreamError instanceof Error
? poolStreamError
: new Error(String(poolStreamError));
logger.warn(`[createMCPStream] ModelPool: member ${poolStreamProviderName} failed`, { error: streamLastError.message });
}
}
// All pool stream members failed.
// Wrap in a plain Error (not a typed error class) so that the
// surrounding fallback orchestrator does not start a second retry layer
// over the already-exhausted pool.
throw new Error(`[ModelPool] all stream members failed: ${streamLastError?.message ?? "no stream members available"}`);
}
// ─── End ModelPool stream path ────────────────────────────────────────────
// 🔧 FIX: Pass enhanced system prompt to real streaming
// Tools will be accessed through the streamText call in executeStream
provider.setFileToolRootPolicy?.(this.fileToolRootsFor(options));
const streamResult = await provider.stream({
...this.deferProviderStreamTTS(options),
systemPrompt: enhancedSystemPrompt, // Use enhanced prompt with tool descriptions
conversationMessages,
});
logger.debug("[createMCPStream] Stream created successfully", {
provider: providerName,
systemPromptPassedLength: enhancedSystemPrompt.length,
});
// Preserve every live descriptor from the provider result. Internal model
// fallback can switch the descriptor's backing result only after iteration
// starts, so eagerly rebuilding selected fields here would re-freeze the
// rejected attempt before the caller drains a chunk.
return preserveLiveStreamAccessors(streamResult, {
...streamResult,
provider: providerName,
model: streamResult.model || options.model,
toolCalls: streamResult.toolCalls ?? [],
toolResults: streamResult.toolResults ?? [],
});
}
/**
* Process stream result
*/
async processStreamResult(_stream, _options, _factoryResult) {
// Simplified placeholder - in the actual implementation this would process the stream
return {
content: "",
usage: undefined,
finishReason: "stop",
toolCalls: [],
toolResults: [],
analytics: undefined,
evaluation: undefined,
};
}
/**
* Emit stream end events
*/
emitStreamEndEvents(streamResult) {
this.emitter.emit("stream:end", {
responseTime: Date.now(),
timestamp: Date.now(),
});
this.emitter.emit("response:end", streamResult.content || "");
}
/**
* Create stream response
*/
createStreamResponse(streamResult, stream, config) {
const source = config.sourceResult;
const responseMetadata = {
streamId: config.streamId,
startTime: config.startTime,
responseTime: config.responseTime,
fallback: config.fallback || false,
guardrailsBlocked: config.guardrailsBlocked,
error: config.error,
};
const response = {
...(source ?? {}),
stream,
provider: source?.provider ?? config.providerName,
model: source?.model ?? config.options.model,
usage: streamResult.usage,
finishReason: streamResult.finishReason,
analytics: streamResult.analytics,
evaluation: streamResult.evaluation,
events: config.events && config.events.length > 0 ? config.events : undefined,
metadata: source ? undefined : Object.assign({}, responseMetadata),
};
// Unresolved #1819 review comment: toolCalls/toolResults must stay bound
// to `streamResult`'s own live getters (defined in stream(), right after
// processStreamResult) rather than the plain values a
// `toolCalls: streamResult.toolCalls` literal above would have captured
// — that reads the getter once and freezes whatever streamState held
// before a top-level cross-provider fallback (handleStreamFallback) had
// a chance to reassign it. Copying the property DESCRIPTOR, not its
// current value, keeps every later read on `response` live too.
const toolCallsDescriptor = Object.getOwnPropertyDescriptor(streamResult, "toolCalls");
if (toolCallsDescriptor) {
Object.defineProperty(response, "toolCalls", toolCallsDescriptor);
}
const toolResultsDescriptor = Object.getOwnPropertyDescriptor(streamResult, "toolResults");
if (toolResultsDescriptor) {
Object.defineProperty(response, "toolResults", toolResultsDescriptor);
}
if (!source) {
return response;
}
// NeuroLink grades the turn's terminal state itself (a normalized
// finishReason, stopReason for aborts and time limits), so those fields
// stay plain values here. Copying the provider's getter for them would
// report the raw vendor reason, e.g. Anthropic's end_turn, instead.
//
// toolCalls/toolResults are excluded for a different reason: `source`
// (the PRIMARY provider's own result) may itself define them as live
// getters over BaseProvider.withStreamModelFallback's `activeResult`
// (#1819) — which only ever tracks a SAME-PROVIDER model retry. Copying
// that descriptor here would silently re-point `response.toolCalls`/
// `toolResults` back at the primary attempt, undoing the descriptor
// copy above that binds them to `streamResult`'s `streamState`-backed
// getters — the ones that actually track a top-level cross-provider
// fallback.
const liveAccessorExclusions = new Set([
"finishReason",
"stopReason",
"rawFinishReason",
"toolCalls",
"toolResults",
]);
for (const key of Object.getOwnPropertyNames(source)) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!liveAccessorExclusions.has(key) &&
descriptor &&
typeof descriptor.get === "function") {
Object.defineProperty(response, key, descriptor);
}
}
Object.defineProperty(response, "metadata", {
enumerable: true,
configurable: true,
get: () => {
// A caller mutation (`result.metadata.x = 1`) must persist across
// reads. The previous `Object.assign(source.metadata ?? {},
// responseMetadata)` allocated a brand-new object every time
// `source.metadata` was undefined, so the mutation landed on a
// value discarded immediately after. Installing a persistent object
// into `source.metadata` the first time makes every later read
// return the SAME object, so mutations on it stick.
if (source.metadata === undefined) {
source.metadata = {};
}
return Object.assign(source.metadata, responseMetadata);
},
set: (value) => {
source.metadata = value;
},
});
return response;
}
/**
* Handle stream error with fallback
*/
async handleStreamError(error, options, startTime, streamId, enhancedOptions, _factoryResult) {
// Curator P1-2: when the pre-dispatch hard cap or post-emergency
// truncation budget check throws ContextBudgetExceededError, the
// payload is too large for the model and a same-payload retry would
// just fail again at the provider — wasting the same tokens that
// the hard cap was meant to save. Rethrow so the caller sees the
// typed error instead of a fallback ProviderError that hides it.
if (error instanceof ContextBudgetExceededError) {
throw error;
}
logger.error("Stream generation failed, attempting fallback", {
error: error instanceof Error ? error.message : String(error),
});
// S1 fix: Emit stream:error so the Pipeline B listener creates an error span.
// S8 fix: The old direct SpanSerializer.createGenerationSpan block is removed —
// the stream:error listener now handles span creation, avoiding duplication.
try {
this.emitter.emit("stream:error", {
content: error instanceof Error ? error.message : String(error),
metadata: {
errorName: error instanceof Error ? error.name : "UnknownError",
durationMs: Date.now() - startTime,
chunkCount: 0,
},
provider: options.provider || "unknown",
model: options.model || "unknown",
});
}
catch {
/* non-blocking */
}
const originalPrompt = options.input.text;
const responseTime = Date.now() - startTime;
const providerName = await getBestProvider(options.provider);
const provider = await AIProviderFactory.createProvider(providerName, options.model, true, undefined, undefined, this.resolveCredentials(options.credentials));
provider.setFileToolRootPolicy?.(this.fileToolRootsFor(options));
const fallbackStreamResult = await provider.stream({
input: { text: options.input.text },
model: options.model,
temperature: options.temperature,
maxTokens: options.maxTokens,
conversationMessages: options.conversationMessages,
});
// Create a wrapper around the fallback stream that accumulates content
let fallbackAccumulatedContent = "";
const fallbackProcessedStream = (async function* (self) {
try {
for await (const chunk of fallbackStreamResult.stream) {
if (chunk &&
"content" in chunk &&
typeof chunk.content === "string") {
fallbackAccumulatedContent += chunk.content;
// Emit chunk event
self.emitter.emit("response:chunk", chunk.content);
}
yield chunk; // Preserve original streaming behavior
}
}
finally {
if (fallbackAccumulatedContent.trim()) {
logger.info(`[NeuroLink.handleStreamError] stream() - COMPLETE SUCCESS (fallback)`, {
provider: providerName,
model: options.model,
responseTimeMs: Date.now() - startTime,
contentLength: fallbackAccumulatedContent.length,
});
// S6 fix: Emit stream:complete after successful fallback so Pipeline B records it.
// Also emit generation:end so advanced analytics tracks the successful fallback
// (stream:complete no longer records analytics — avoids double-counting elsewhere).
try {
const fallbackModel = options.model || "unknown";
const fallbackDuration = Date.now() - startTime;
self.emitter.emit("stream:complete", {
content: fallbackAccumulatedContent,
provider: providerName,
model: fallbackModel,
finishReason: "stop",
metadata: {
durationMs: fallbackDuration,
chunkCount: 0,
totalLength: fallbackAccumulatedContent.length,
isFallback: true,
finishReason: "stop",
},
});
self.emitter.emit("generation:end", {
provider: providerName,
model: fallbackModel,
responseTime: fallbackDuration,
timestamp: Date.now(),
result: {
content: fallbackAccumulatedContent,
usage: { input: 0, output: 0, total: 0 },
model: fallbackModel,
provider: providerName,
finishReason: "stop",
},
success: true,
pipelineAHandled: true,
});
}
catch {
/* non-blocking */
}
}
// Store memory after fallback stream consumption is complete
// Guard: skip storing if fallback accumulated content is empty
if (self.conversationMemory &&
enhancedOptions?.context?.sessionId &&
fallbackAccumulatedContent.trim()) {
const sessionId = enhancedOptions?.context?.sessionId;
const userId = enhancedOptions?.context
?.userId;
let providerDetails;
if (options.model) {
providerDetails = {
provider: providerName,
model: options.model,
};
}
const memStoreStart = Date.now();
try {
const fallbackSessionId = sessionId || options.context?.sessionId;
const pendingSkillMessages = self.drainPendingSkillMessages(fallbackSessionId);
await self.conversationMemory.storeConversationTurn({
sessionId: fallbackSessionId,
userId: userId || options.context?.userId,
userMessage: originalPrompt ?? "",
aiResponse: fallbackAccumulatedContent,
startTimeStamp: new Date(startTime),
providerDetails,
enableSummarization: enhancedOptions?.enableSummarization,
requestId: enhancedOptions?.context?.requestId ||
options.context
?.requestId,
...(pendingSkillMessages.length > 0
? { skillMessages: pendingSkillMessages }
: {}),
});
self.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "fallback-stream" }, Date.now() - memStoreStart, SpanStatus.OK);
}
catch (error) {
self.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "fallback-stream" }, Date.now() - memStoreStart, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
logger.warn("Failed to store fallback stream conversation turn", {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
})(this);
return {
stream: fallbackProcessedStream,
provider: providerName,
model: options.model,
usage: fallbackStreamResult.usage,
finishReason: fallbackStreamResult.finishReason || "stop",
toolCalls: fallbackStreamResult.toolCalls || [],
toolResults: fallbackStreamResult.toolResults || [],
analytics: fallbackStreamResult.analytics,
evaluation: fallbackStreamResult.evaluation,
metadata: {
streamId,
startTime,
responseTime,
fallback: true,
},
};
}
/**
* Get the EventEmitter instance to listen to NeuroLink events for real-time monitoring and debugging.
* This method provides access to the internal event system that emits events during AI generation,
* tool execution, streaming, and other operations for comprehensive observability.
*
* @returns EventEmitter instance that emits various NeuroLink operation events
*
* @example
* ```typescript
* // Basic event listening setup
* const neurolink = new NeuroLink();
* const emitter = neurolink.getEventEmitter();
*
* // Listen to generation events
* emitter.on('generation:start', (event) => {
* console.log(`Generation started with provider: ${event.provider}`);
* console.log(`Started at: ${new Date(event.timestamp)}`);
* });
*
* emitter.on('generation:end', (event) => {
* console.log(`Generation completed in ${event.responseTime}ms`);
* console.log(`Tools used: ${event.toolsUsed?.length || 0}`);
* });
*
* // Listen to streaming events
* emitter.on('stream:start', (event) => {
* console.log(`Streaming started with provider: ${event.provider}`);
* });
*
* emitter.on('stream:end', (event) => {
* console.log(`Streaming completed in ${event.responseTime}ms`);
* if (event.fallback) console.log('Used fallback streaming');
* });
*
* // Listen to tool execution events
* emitter.on('tool:start', (event) => {
* console.log(`Tool execution started: ${event.toolName}`);
* });
*
* emitter.on('tool:end', (event) => {
* console.log(`Tool ${event.toolName} ${event.success ? 'succeeded' : 'failed'}`);
* console.log(`Execution time: ${event.responseTime}ms`);
* });
*
* // Listen to tool registration events
* emitter.on('tools-register:start', (event) => {
* console.log(`Registering tool: ${event.toolName}`);
* });
*
* emitter.on('tools-register:end', (event) => {
* console.log(`Tool registration ${event.success ? 'succeeded' : 'failed'}: ${event.toolName}`);
* });
*
* // Listen to external MCP server events
* emitter.on('externalMCP:serverConnected', (event) => {
* console.log(`External MCP server connected: ${event.serverId}`);
* console.log(`Tools available: ${event.toolCount || 0}`);
* });
*
* emitter.on('externalMCP:serverDisconnected', (event) => {
* console.log(`External MCP server disconnected: ${event.serverId}`);
* console.log(`Reason: ${event.reason || 'Unknown'}`);
* });
*
* emitter.on('externalMCP:toolDiscovered', (event) => {
* console.log(`New tool discovered: ${event.toolName} from ${event.serverId}`);
* });
*
* // Advanced usage with error handling
* emitter.on('error', (error) => {
* console.error('NeuroLink error:', error);
* });
*
* // Clean up event listeners when done
* function cleanup() {
* emitter.removeAllListeners();
* }
*
* process.on('SIGINT', cleanup);
* process.on('SIGTERM', cleanup);
* ```
*
* @example
* ```typescript
* // Advanced monitoring with metrics collection
* const neurolink = new NeuroLink();
* const emitter = neurolink.getEventEmitter();
* const metrics = {
* generations: 0,
* totalResponseTime: 0,
* toolExecutions: 0,
* failures: 0
* };
*
* // Collect performance metrics
* emitter.on('generation:end', (event) => {
* metrics.generations++;
* metrics.totalResponseTime += event.responseTime;
* metrics.toolExecutions += event.toolsUsed?.length || 0;
* });
*
* emitter.on('tool:end', (event) => {
* if (!event.success) {
* metrics.failures++;
* }
* });
*
* // Log metrics every 10 seconds
* setInterval(() => {
* const avgResponseTime = metrics.generations > 0
* ? metrics.totalResponseTime / metrics.generations
* : 0;
*
* console.log('NeuroLink Metrics:', {
* totalGenerations: metrics.generations,
* averageResponseTime: `${avgResponseTime.toFixed(2)}ms`,
* totalToolExecutions: metrics.toolExecutions,
* failureRate: `${((metrics.failures / (metrics.toolExecutions || 1)) * 100).toFixed(2)}%`
* });
* }, 10000);
* ```
*
* **Available Events:**
*
* **Generation Events:**
* - `generation:start` - Fired when text generation begins
* - `{ provider: string, timestamp: number }`
* - `generation:end` - Fired when text generation completes (or fails / is aborted)
* - `{ provider: string, responseTime: number, toolsUsed?: string[], timestamp: number, success?: boolean, aborted?: boolean, error?: string }`
* - `success` is `false` for both failures and client aborts; `aborted: true`
* distinguishes the latter so consumers can route cancellations
* differently from real errors. Pipeline B's metrics span maps
* `aborted: true` events to `SpanStatus.WARNING` (not ERROR).
*
* **Streaming Events:**
* - `stream:start` - Fired when streaming begins
* - `{ provider: string, timestamp: number }`
* - `stream:end` - Fired when streaming completes
* - `{ provider: string, responseTime: number, fallback?: boolean }`
*
* **Tool Events:**
* - `tool:start` - Fired when tool execution begins
* - `{ toolName: string, timestamp: number }`
* - `tool:end` - Fired when tool execution completes
* - `{ toolName: string, responseTime: number, success: boolean, timestamp: number }`
* - `tools-register:start` - Fired when tool registration begins
* - `{ toolName: string, timestamp: number }`
* - `tools-register:end` - Fired when tool registration completes
* - `{ toolName: string, success: boolean, timestamp: number }`
*
* **External MCP Events:**
* - `externalMCP:serverConnected` - Fired when external MCP server connects
* - `{ serverId: string, toolCount?: number, timestamp: number }`
* - `externalMCP:serverDisconnected` - Fired when external MCP server disconnects
* - `{ serverId: string, reason?: string, timestamp: number }`
* - `externalMCP:serverFailed` - Fired when external MCP server fails
* - `{ serverId: string, error: string, timestamp: number }`
* - `externalMCP:toolDiscovered` - Fired when external MCP tool is discovered
* - `{ toolName: string, serverId: string, timestamp: number }`
* - `externalMCP:toolRemoved` - Fired when external MCP tool is removed
* - `{ toolName: string, serverId: string, timestamp: number }`
* - `externalMCP:serverAdded` - Fired when external MCP server is added
* - `{ serverId: string, config: MCPServerInfo, toolCount: number, timestamp: number }`
* - `externalMCP:serverRemoved` - Fired when external MCP server is removed
* - `{ serverId: string, timestamp: number }`
*
* **Error Events:**
* - `error` - Fired when an error occurs
* - `{ error: Error, context?: object }`
*
* @throws {Error} This method does not throw errors as it returns the internal EventEmitter
*
* @since 1.0.0
* @see {@link https://nodejs.org/api/events.html} Node.js EventEmitter documentation
* @see {@link NeuroLink.generate} for events related to text generation
* @see {@link NeuroLink.stream} for events related to streaming
* @see {@link NeuroLink.executeTool} for events related to tool execution
*/
getEventEmitter() {
return this.emitter;
}
/**
* Whether a HITL confirmation is still awaiting a response on THIS instance.
*
* Emitting `hitl:confirmation-response` is not proof the decision landed. The
* forwarding listener for that event is installed once at construction, so
* `emitter.emit(...)` reports a listener was invoked even when nothing is
* waiting — the pending set lives one hop further in, on the HITL manager, and
* holds the `resolve`/`reject` of the suspended tool call. An instance built
* after the confirmation was issued (a session rebuilt from persisted state)
* therefore accepts the event and resolves nothing.
*
* Returns `false` in two different situations, which it deliberately does not
* distinguish: HITL was never configured on this instance, and the id is
* unknown or already settled. Both mean "emitting a response here achieves
* nothing", which is the question this answers. A caller that needs to tell a
* configuration mistake from an expired confirmation should check the HITL
* config separately rather than read that into this boolean.
*
* This is advisory, not atomic: it reports the state at the moment it is
* called. Nothing stops the confirmation timing out immediately afterwards, so
* emit on the answer without an `await` in between. Over the case it exists
* for — an instance rebuilt from persisted state, whose pending set is empty
* and can never repopulate for an id it never issued — absence cannot become
* presence, so the answer cannot go stale in the unsafe direction.
*
* @param confirmationId - The id from the `hitl:confirmation-request` event
* @returns `true` only if this instance is still holding that confirmation
*
* @example
* ```typescript
* if (!neurolink.hasPendingHITLConfirmation(confirmationId)) {
* return refuse("This conversation has expired, so the action was not carried out.");
* }
* neurolink.getEventEmitter().emit("hitl:confirmation-response", { ... });
* ```
*/
hasPendingHITLConfirmation(confirmationId) {
return this.hitlManager?.hasPendingConfirmation(confirmationId) ?? false;
}
/**
* Returns the instance-level tool-dedup configuration, or `undefined` when
* toolDedup was not provided at construction time.
*
* The stored object is returned as-is whenever `toolDedup` was supplied,
* including when `enabled: false` — only the complete absence of a `toolDedup`
* option results in `undefined`.
*
* Called by `BaseProvider.applyToolFiltering` so the dedup pass uses the
* same config for every generate/stream call without threading an extra
* parameter through the full call stack.
*/
getToolDedupConfig() {
return this.toolDedupConfig;
}
/**
* Returns the instance-level `tools` config (master switch, include/exclude
* lists, discovery mode), or `undefined` when not provided at construction.
*
* Called by `BaseProvider.applyToolFiltering` so the tool gate composes the
* instance policy with per-call options on every generate/stream call.
*/
/**
* The policy an entry point bound to `options`, or — for an internal path
* that built its own options — the instance policy narrowed by any
* per-call `toolRoots`. Never trusts a public field.
*/
fileToolRootsFor(options) {
return (boundFileToolRoots(options) ??
this.resolveFileToolRoots(options.toolRoots));
}
/**
* A worker's tool config: it inherits this instance's file-tool roots, and a
* worker-level `fileRoots` may only narrow them (throws otherwise).
*/
workerToolsConfig(override) {
const hostRoots = this.toolsConfig?.fileRoots;
if (override?.fileRoots === undefined) {
return hostRoots === undefined
? override
: { ...override, fileRoots: hostRoots };
}
const narrowed = resolveFileToolRootPolicy({
perCall: override.fileRoots,
instance: hostRoots,
}).roots;
return { ...override, fileRoots: narrowed ? [...narrowed] : [] };
}
/** File-tool roots for one call: per-call roots narrow the instance's `tools.fileRoots`. */
resolveFileToolRoots(perCall) {
return resolveFileToolRootPolicy({
perCall,
instance: this.toolsConfig?.fileRoots,
});
}
getToolsConfig() {
return this.toolsConfig;
}
/**
* Tools discovered via `search_tools` for a session (`tools.discovery`
* mode). Pinned tools are sent in full on every subsequent call of that
* session instead of being deferred — discovery cost is paid once.
* Reading refreshes the session's recency (LRU), so active conversations
* are never the ones evicted at the session cap.
*/
getDiscoveryPins(sessionKey) {
const pins = this.discoveryPins.get(sessionKey);
if (!pins) {
return new Set();
}
// Map iteration order is insertion order — re-inserting on access turns
// the eviction below into LRU rather than FIFO.
this.discoveryPins.delete(sessionKey);
this.discoveryPins.set(sessionKey, pins);
return pins;
}
/**
* Pin discovered tools to a session (called by the `search_tools`
* meta-tool on hydration). Append-only within a session; the map is
* bounded by evicting the least-recently-used session past 1000 sessions.
*/
pinDiscoveredTools(sessionKey, toolNames) {
let pins = this.discoveryPins.get(sessionKey);
if (!pins) {
pins = new Set();
}
else {
this.discoveryPins.delete(sessionKey);
}
this.discoveryPins.set(sessionKey, pins);
for (const name of toolNames) {
pins.add(name);
}
if (this.discoveryPins.size > 1000) {
const oldest = this.discoveryPins.keys().next().value;
if (oldest !== undefined) {
this.discoveryPins.delete(oldest);
}
}
}
/**
* Curator P1-1: synchronous credential health check for a single provider.
*
* Drives a tiny real call against the provider (1-token completion or
* `/models` listing depending on provider) to confirm the configured
* credentials are valid. Useful at startup so a service can refuse to
* boot if its primary provider's credentials are broken instead of
* discovering the problem on first user request.
*
* @example
* ```ts
* const health = await neurolink.checkCredentials({ provider: "litellm" });
* if (health.status !== "ok") {
* throw new Error(`provider not ready: ${health.detail}`);
* }
* ```
*
* @param input - the provider to check
* @returns `{ provider, status, detail }`. Possible status values:
* - `"ok"` — credentials valid and provider reachable
* - `"missing"` — required env / credentials not configured
* - `"expired"` — credentials present but rejected (401/403)
* - `"denied"` — credentials valid but team not whitelisted for any model
* - `"network"` — provider unreachable (timeout, ECONNREFUSED, DNS)
* - `"unknown"` — other error; consult `detail`
*/
async checkCredentials(input) {
const { provider, model } = input;
const probeText = "ping";
try {
// 1-token probe is cheap, exercises auth + routing without much cost.
await this.generate({
provider: provider,
...(model && { model }),
input: { text: probeText },
maxTokens: 16,
disableTools: true,
});
return { provider, status: "ok", detail: "credentials valid" };
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const lower = msg.toLowerCase();
if (err instanceof ModelAccessDeniedError) {
return {
provider,
status: "denied",
detail: msg,
};
}
if (lower.includes("authentication") ||
lower.includes("401") ||
lower.includes("invalid api key") ||
lower.includes("incorrect api key") ||
lower.includes("api_key_invalid") ||
lower.includes("token has expired") ||
lower.includes("expired credentials")) {
return { provider, status: "expired", detail: msg };
}
if (lower.includes("not configured") ||
lower.includes("missing api") ||
lower.includes("api key is required") ||
lower.includes("no api key") ||
lower.includes("application default credentials") ||
lower.includes("google_application_credentials") ||
lower.includes("project_id") ||
lower.includes("default credentials") ||
lower.includes("service account")) {
return { provider, status: "missing", detail: msg };
}
if (lower.includes("econnrefused") ||
lower.includes("enotfound") ||
lower.includes("could not resolve") ||
lower.includes("timeout") ||
lower.includes("network") ||
lower.includes("cannot connect")) {
return { provider, status: "network", detail: msg };
}
return { provider, status: "unknown", detail: msg };
}
}
// ========================================
// ENHANCED: Tool Event Emission API
// ========================================
// TODO(#1576): Add ToolExecutionEvent utility methods in future version
// Will provide structured event format for consistent tool event processing
/**
* Emit tool start event with execution tracking
* @param toolName - Name of the tool being executed
* @param input - Input parameters for the tool
* @param startTime - Timestamp when execution started
* @returns executionId for tracking this specific execution
*/
emitToolStart(toolName, input, startTime = Date.now()) {
const executionId = `${toolName}-${startTime}-${Math.random().toString(36).substr(2, 9)}`;
// Create execution context for tracking
const context = {
executionId,
tool: toolName,
startTime,
metadata: {
inputType: typeof input,
hasInput: input !== undefined && input !== null,
},
};
// Store in active executions
this.activeToolExecutions.set(executionId, context);
this.currentStreamToolExecutions.push(context);
// Emit event (NeuroLinkEvents format for compatibility)
this.emitter.emit("tool:start", createToolEventPayload(toolName, {
input,
timestamp: startTime,
executionId,
}));
logger.debug(`tool:start emitted for ${toolName}`, {
toolName,
executionId,
timestamp: startTime,
inputProvided: input !== undefined,
});
return executionId;
}
/**
* Emit tool end event with execution summary
* @param toolName - Name of the tool that finished
* @param result - Result from the tool execution
* @param error - Error message if execution failed
* @param startTime - When execution started
* @param endTime - When execution finished
* @param executionId - Optional execution ID for tracking
*/
emitToolEnd(toolName, result, error, startTime, endTime = Date.now(), executionId) {
const actualStartTime = startTime || endTime - 1000; // Fallback if no start time
const duration = endTime - actualStartTime;
const success = !error;
// Find execution context or create fallback
let context;
if (executionId) {
context = this.activeToolExecutions.get(executionId);
}
else {
// Find by tool name (fallback for executions without ID tracking)
context = Array.from(this.activeToolExecutions.values()).find((ctx) => ctx.tool === toolName && !ctx.endTime);
}
const finalExecutionId = executionId ||
context?.executionId ||
`${toolName}-${actualStartTime}-fallback-${Math.random().toString(36).substr(2, 9)}`;
// Update execution context
if (context) {
context.endTime = endTime;
context.result = result;
context.error = error;
this.activeToolExecutions.delete(context.executionId);
}
// Create execution summary
const summary = {
tool: toolName,
startTime: actualStartTime,
endTime,
duration,
success,
result,
error,
executionId: finalExecutionId,
metadata: {
toolCategory: "custom", // Default, can be overridden
},
};
// Store in history
this.toolExecutionHistory.push(summary);
// Emit event (NeuroLinkEvents format for compatibility)
this.emitter.emit("tool:end", createToolEventPayload(toolName, {
result,
error,
success,
responseTime: duration,
timestamp: endTime,
duration,
executionId: finalExecutionId,
}));
logger.debug(`tool:end emitted for ${toolName}`, {
toolName,
executionId: finalExecutionId,
duration,
success,
hasResult: result !== undefined,
hasError: !!error,
});
}
/**
* Get current tool execution contexts for stream metadata
*/
getCurrentToolExecutions() {
return [...this.currentStreamToolExecutions];
}
/**
* Get tool execution history
*/
getToolExecutionHistory() {
return [...this.toolExecutionHistory];
}
/**
* Clear current stream tool executions (called at stream start)
*/
clearCurrentStreamExecutions() {
this.currentStreamToolExecutions = [];
}
// TODO(#1576): Add getToolExecutionEvents() method in future version
// Will return properly formatted ToolExecutionEvent objects for structured event processing
// ========================================
// Tool Registration API
// ========================================
/**
* Register a custom tool that will be available to all AI providers
* @param name - Unique name for the tool
* @param tool - Tool in MCPExecutableTool format (unified MCP protocol type)
*/
registerTool(name, tool, options) {
this.invalidateToolCache(); // Invalidate cache when a tool is registered
// Emit tool registration start event
this.emitter.emit("tools-register:start", {
toolName: name,
timestamp: Date.now(),
});
try {
if (!name || typeof name !== "string") {
throw new Error("Invalid tool name");
}
if (!tool || typeof tool !== "object") {
throw new Error(`Invalid tool object provided for tool: ${name}`);
}
if (typeof tool.execute !== "function") {
throw new Error(`Tool '${name}' must have an execute method.`);
}
if (name.trim() === "") {
throw new Error("Tool name cannot be empty");
}
if (name.length > 100) {
throw new Error("Tool name is too long (maximum 100 characters)");
}
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F]/.test(name)) {
throw new Error("Tool name contains invalid control characters");
}
// Proceed with tool registration since validation passed
// Convert tool to proper MCPExecutableTool format with schema conversion
const convertedTool = {
name: tool.name || name,
description: tool.description || name,
execute: tool.execute,
inputSchema: (() => {
// Check if tool has 'parameters' field (SDK SimpleTool format)
if ("parameters" in tool && tool.parameters) {
if (isZodSchema(tool.parameters)) {
return tool.parameters;
}
// If it's already a JSON Schema object, return as-is
if (typeof tool.parameters === "object") {
return tool.parameters;
}
}
// Fall back to existing inputSchema or empty object
const fallbackSchema = tool.inputSchema || {};
return fallbackSchema;
})(),
};
// Wrap execute with per-tool timeout if specified at registration.
// Uses AbortSignal.timeout() composed with any parent signal from the AI SDK.
// This ensures the timeout works regardless of the execution path
// (direct executeTool() or AI SDK generateText() tool calling).
if (options?.timeout !== undefined &&
options.timeout > 0 &&
Number.isFinite(options.timeout) &&
typeof convertedTool.execute === "function") {
const originalExecute = convertedTool.execute;
const toolTimeout = options.timeout;
const toolName = name;
convertedTool.execute = async (...args) => {
const timeoutSignal = AbortSignal.timeout(toolTimeout);
// Compose with any parent abortSignal from ToolExecutionOptions
const execOptions = args[1];
const parentSignal = execOptions?.abortSignal;
const composedSignal = parentSignal
? AbortSignal.any([parentSignal, timeoutSignal])
: timeoutSignal;
// Replace the abortSignal in execution options
const augmentedContext = {
...execOptions,
abortSignal: composedSignal,
};
return Promise.race([
originalExecute(args[0], augmentedContext),
new Promise((_, reject) => {
composedSignal.addEventListener("abort", () => {
if (timeoutSignal.aborted) {
reject(ErrorFactory.toolTimeout(toolName, toolTimeout));
}
else {
reject(new DOMException("The operation was aborted", "AbortError"));
}
}, { once: true });
}),
]);
};
}
// SMART DEFAULTS: Use utility to eliminate boilerplate creation
const mcpServerInfo = createCustomToolServerInfo(name, convertedTool, options?.timeout, options?.maxRetries, options?.totalTimeoutMs);
// Register with toolRegistry using MCPServerInfo directly
this.toolRegistry.registerServer(mcpServerInfo);
// Re-registration replaces options wholesale: omitting `cacheable`
// clears a previous `cacheable: false`. A caller replacing a tool whose
// results are live state must repeat the flag.
if (options?.cacheable === false) {
this.uncacheableTools.add(name);
}
else {
this.uncacheableTools.delete(name);
}
// Emit tool registration success event
this.emitter.emit("tools-register:end", {
toolName: name,
success: true,
timestamp: Date.now(),
timeoutMs: options?.timeout,
});
}
catch (error) {
logger.error(`Failed to register tool ${name}:`, error);
throw error;
}
}
/**
* Set the context that will be passed to tools during execution
* This context will be merged with any runtime context passed by the AI model
* @param context - Context object containing session info, tokens, shop data, etc.
*/
setToolContext(context) {
this.toolExecutionContext = { ...context };
logger.debug("Tool execution context updated", {
sessionId: context.sessionId,
contextKeys: Object.keys(context),
hasJuspayToken: !!context.juspayToken,
hasShopId: !!context.shopId,
});
}
/**
* Get the current tool execution context
* @returns Current context or undefined if not set
*/
getToolContext() {
return this.toolExecutionContext
? { ...this.toolExecutionContext }
: undefined;
}
/**
* Clear the tool execution context
*/
clearToolContext() {
this.toolExecutionContext = undefined;
logger.debug("Tool execution context cleared");
}
/**
* Register multiple tools at once - Supports both object and array formats
* @param tools - Object mapping tool names to MCPExecutableTool format OR Array of tools with names
*
* Object format (existing): { toolName: MCPExecutableTool, ... }
* Array format (Lighthouse compatible): [{ name: string, tool: MCPExecutableTool }, ...]
*/
registerTools(tools) {
if (Array.isArray(tools)) {
// Handle array format (Lighthouse compatible)
for (const { name, tool } of tools) {
this.registerTool(name, tool);
}
}
else {
// Handle object format (existing compatibility)
for (const [name, tool] of Object.entries(tools)) {
this.registerTool(name, tool);
}
}
}
/**
* Unregister a custom tool
* @param name - Name of the tool to remove
* @returns true if the tool was removed, false if it didn't exist
*/
unregisterTool(name) {
this.invalidateToolCache(); // Invalidate cache when a tool is unregistered
const serverId = `custom-tool-${name}`;
const removed = this.toolRegistry.unregisterServer(serverId);
if (removed) {
this.uncacheableTools.delete(name);
logger.info(`Unregistered custom tool: ${name}`);
}
return removed;
}
// ==================== MCP Enhancement Public APIs ====================
/**
* Register a global tool middleware that runs on every tool execution.
* Middleware receives the tool, params, context, and a next() function.
* @param middleware - The middleware function to register
* @returns this (for chaining)
*/
useToolMiddleware(middleware) {
this.mcpToolMiddlewares.push(middleware);
logger.debug(`[NeuroLink] Registered tool middleware (total: ${this.mcpToolMiddlewares.length})`);
return this;
}
/**
* Get all registered tool middlewares
*/
getToolMiddlewares() {
return [...this.mcpToolMiddlewares];
}
/**
* Flush any pending batched tool calls immediately
*/
async flushToolBatch() {
if (this.mcpToolBatcher) {
await this.mcpToolBatcher.flush();
}
}
/**
* Get the current MCP enhancements configuration
*/
getMCPEnhancementsConfig() {
return this.mcpEnhancementsConfig;
}
/**
* Update agentic loop report metadata for a conversation session.
* Upserts a report entry by reportId — updates existing or adds new.
* Only supported when using Redis conversation memory.
*
* @param sessionId The session identifier
* @param report The agentic loop report metadata to upsert
* @param userId Optional user identifier
* @throws Error if conversation memory is not initialized or is not Redis-backed
*
* @example
* ```typescript
* await neurolink.updateAgenticLoopReport("session-123", {
* reportId: "report-abc",
* reportType: "META",
* reportStatus: "INPROGRESS",
* });
* ```
*/
async updateAgenticLoopReport(sessionId, report, userId) {
if (!this.conversationMemory) {
throw new ConversationMemoryError("Conversation memory is not initialized. Enable conversationMemory in NeuroLink options.", "CONFIG_ERROR");
}
// Check if the memory manager is Redis-backed (has updateAgenticLoopReport method)
if (!("updateAgenticLoopReport" in this.conversationMemory) ||
typeof this.conversationMemory.updateAgenticLoopReport !== "function") {
throw new ConversationMemoryError("updateAgenticLoopReport is only supported with Redis conversation memory.", "CONFIG_ERROR");
}
await withTimeout(this
.conversationMemory.updateAgenticLoopReport(sessionId, userId, report), 5000);
}
/**
* Get all registered custom tools
* @returns Map of tool names to MCPExecutableTool format
*/
getCustomTools() {
// Get tools from toolRegistry with smart category detection
const customTools = this.toolRegistry.getToolsByCategory(detectCategory({ isCustomTool: true }));
const toolMap = new Map();
for (const tool of customTools) {
const effectiveSchema = tool.inputSchema || tool.parameters;
logger.debug(`Processing tool schema for Claude`, {
toolName: tool.name,
hasDescription: !!tool.description,
description: tool.description,
hasParameters: !!tool.parameters,
parametersType: typeof tool.parameters,
parametersKeys: tool.parameters && typeof tool.parameters === "object"
? Object.keys(tool.parameters)
: "NOT_OBJECT",
hasInputSchema: !!tool.inputSchema,
inputSchemaType: typeof tool.inputSchema,
inputSchemaKeys: tool.inputSchema && typeof tool.inputSchema === "object"
? Object.keys(tool.inputSchema)
: "NOT_OBJECT",
hasEffectiveSchema: !!effectiveSchema,
effectiveSchemaType: typeof effectiveSchema,
effectiveSchemaHasProperties: !!effectiveSchema?.properties,
effectiveSchemaHasRequired: !!effectiveSchema?.required,
originalInputSchema: tool.inputSchema,
phase: "AFTER_SCHEMA_FIX",
timestamp: Date.now(),
});
// Return MCPServerInfo.tools format directly - no conversion needed
toolMap.set(tool.name, {
name: tool.name,
description: tool.description || "",
inputSchema: typeof tool.inputSchema === "object" && tool.inputSchema !== null
? tool.inputSchema
: typeof tool.parameters === "object" && tool.parameters !== null
? tool.parameters
: {},
execute: async (params, context) => {
// CONTEXT MERGING: Combine all available contexts for maximum information
const storedContext = this.toolExecutionContext || {};
const runtimeContext = context && isNonNullObject(context)
? context
: {};
// Merge contexts with runtime context taking precedence
// This ensures we have the richest possible context for tool execution
const executionContext = {
...storedContext, // Base context from setToolContext (session, tokens, etc.)
...runtimeContext, // Runtime context from AI model (if any)
// Ensure we always have at least a sessionId for tracing
sessionId: runtimeContext.sessionId ||
storedContext.sessionId ||
`fallback-${Date.now()}`,
};
// Enhanced logging for context debugging
logger.debug("Tool execution context merged", {
toolName: tool.name,
storedContextKeys: Object.keys(storedContext),
runtimeContextKeys: Object.keys(runtimeContext),
finalContextKeys: Object.keys(executionContext),
hasJuspayToken: !!executionContext
.juspayToken,
hasShopId: !!executionContext.shopId,
sessionId: executionContext.sessionId,
});
return await this.toolRegistry.executeTool(tool.name, params, executionContext);
},
});
}
// Inject file reference tools so they reach the Vercel AI SDK's tools parameter.
// These tools are bound to this.fileRegistry and allow the LLM to read/search
// files on demand instead of having all file content dumped into the prompt.
//
// createFileTools() returns Vercel AI SDK tool() objects with Zod `parameters`.
// We pass `parameters` as `inputSchema` so processCustomTools() in ToolsManager
// recognises it as a Zod schema (priority 2) and serialises it correctly for
// every provider — including Vertex AI which rejects bare `{}` schemas.
// Cache to avoid redundant allocations per generate/stream call (FRT-6).
if (!this.cachedFileTools) {
this.cachedFileTools = createFileTools(this.fileRegistry);
}
const fileTools = this.cachedFileTools;
for (const [toolName, toolDef] of Object.entries(fileTools)) {
if (!toolMap.has(toolName)) {
const toolDefRecord = toolDef;
const toolParams = toolDefRecord.inputSchema ?? toolDefRecord.parameters;
toolMap.set(toolName, {
name: toolName,
description: toolDef.description || `File tool: ${toolName}`,
inputSchema: typeof toolParams === "object" && toolParams !== null
? toolParams
: { type: "object", properties: {} },
execute: async (params) => {
return await toolDef.execute(params, {
toolCallId: `file-tool-${Date.now()}`,
messages: [],
});
},
});
}
}
return toolMap;
}
/**
* Add an in-memory MCP server (from git diff)
* Allows registration of pre-instantiated server objects
* @param serverId - Unique identifier for the server
* @param serverInfo - Server configuration
*/
async addInMemoryMCPServer(serverId, serverInfo) {
this.invalidateToolCache(); // Invalidate cache when a server is added
try {
mcpLogger.debug(`[NeuroLink] Registering in-memory MCP server: ${serverId}`);
// Initialize tools array if not provided
if (!serverInfo.tools) {
serverInfo.tools = [];
}
// ZERO CONVERSIONS: Pass MCPServerInfo directly to toolRegistry
await this.toolRegistry.registerServer(serverInfo);
mcpLogger.info(`[NeuroLink] Successfully registered in-memory server: ${serverId}`, {
category: serverInfo.metadata?.category,
provider: serverInfo.metadata?.provider,
version: serverInfo.metadata?.version,
});
}
catch (error) {
mcpLogger.error(`[NeuroLink] Failed to register in-memory server ${serverId}:`, error);
throw error;
}
}
/**
* Get all registered in-memory servers as a Map for ID-based lookup.
*
* This method is primarily used when you need O(1) lookup by server ID,
* such as in `testMCPServer()` for checking if a specific server exists.
*
* @returns Map of server IDs to MCPServerInfo
* @see {@link getInMemoryServerInfos} for array-based access (useful for iteration/spreading)
*/
getInMemoryServers() {
// Reuse getInMemoryServerInfos() to avoid duplicating filter logic
const serverInfos = this.getInMemoryServerInfos();
const serverMap = new Map();
for (const serverInfo of serverInfos) {
serverMap.set(serverInfo.id, serverInfo);
}
return serverMap;
}
/**
* Get in-memory servers as an array of MCPServerInfo.
*
* This method is the canonical source for in-memory server filtering.
* It fetches from the centralized tool registry and filters servers
* with the "in-memory" category.
*
* Use this method when you need to:
* - Iterate over all in-memory servers
* - Spread servers into another array (e.g., in `listMCPServers()`)
* - Get a count of in-memory servers
*
* @returns Array of MCPServerInfo for in-memory servers
* @see {@link getInMemoryServers} for Map-based access (useful for ID lookups)
*/
getInMemoryServerInfos() {
// Get in-memory servers from centralized tool registry
const allServers = this.toolRegistry.getBuiltInServerInfos();
return allServers.filter((server) => detectCategory({
existingCategory: server.metadata?.category,
serverId: server.id,
}) === "in-memory");
}
/**
* Get auto-discovered servers as MCPServerInfo - ZERO conversion needed
* @returns Array of MCPServerInfo
*/
getAutoDiscoveredServerInfos() {
return this.autoDiscoveredServerInfos;
}
/**
* Execute a specific tool by name with robust error handling
* Supports both custom tools and MCP server tools with timeout, retry, and circuit breaker patterns
* @param toolName - Name of the tool to execute
* @param params - Parameters to pass to the tool
* @param options - Execution options including optional authentication context
* @returns Tool execution result
*/
async executeTool(toolName, params = {}, options) {
if (this.mcpToolBatcher && !options?.bypassBatcher) {
return this.mcpToolBatcher.execute(toolName, params);
}
const executionContext = this.createToolExecutionContext(toolName, params, options);
return tracers.mcp.startActiveSpan("neurolink.tool.execute", {
attributes: {
"tool.name": toolName,
"tool.type": executionContext.toolType,
"tool.input_size": executionContext.inputSize,
"tool.input_preview": executionContext.truncatedInput,
// NOT marked langfuse.internal: this is the public entrypoint for
// `NeuroLink.executeTool()`. Direct API callers (not going through
// the AI SDK) would otherwise produce zero Langfuse observations —
// the lower-level registry/discovery spans are internal wrappers.
// AI-SDK-initiated custom tools will produce both ai.toolCall and
// this span, which is the accepted tradeoff for keeping direct
// invocations observable.
},
}, (toolSpan) => this.executeToolWithSpan(toolName, params, options, executionContext, toolSpan));
}
createToolExecutionContext(toolName, params, options) {
const externalTool = this.externalServerManager
.getAllTools()
.find((tool) => tool.name === toolName);
const toolType = externalTool
? "mcp"
: this.getCustomTools().has(toolName)
? "custom"
: "external";
const inputStr = params ? stringifyContentSafe(params) : "";
const executionStartTime = Date.now();
// Per-invocation id so consumers can correlate a tool:start with its matching
// tool:end even when the same tool runs multiple times concurrently.
const executionId = `${toolName}-${executionStartTime}-${Math.random()
.toString(36)
.slice(2, 11)}`;
return {
functionTag: "NeuroLink.executeTool",
executionStartTime,
executionId,
externalTool,
toolType,
inputSize: inputStr.length,
truncatedInput: inputStr.length > 2048 ? inputStr.substring(0, 2048) : inputStr,
options,
hitlState: { triggered: false },
};
}
async executeToolWithSpan(toolName, params, options, executionContext, toolSpan) {
try {
const prepared = await this.prepareToolExecutionState(toolName, params, options, executionContext);
return await this.runPreparedToolExecution(toolName, params, prepared, executionContext, toolSpan);
}
catch (outerError) {
if (!(outerError instanceof NeuroLinkError)) {
const errMsg = outerError instanceof Error ? outerError.message : String(outerError);
toolSpan.recordException(outerError instanceof Error ? outerError : new Error(errMsg));
toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
}
throw outerError;
}
finally {
toolSpan.end();
}
}
async prepareToolExecutionState(toolName, params, options, executionContext) {
logger.debug(`[${executionContext.functionTag}] Tool execution requested:`, {
toolName,
params: isNonNullObject(params)
? transformParamsForLogging(params)
: params,
hasExternalManager: !!this.externalServerManager,
});
logger.debug(`Tool execution detailed analysis`, {
toolName,
executionStartTime: executionContext.executionStartTime,
paramsAnalysis: {
type: typeof params,
isNull: params === null,
isUndefined: params === undefined,
isEmpty: params &&
typeof params === "object" &&
Object.keys(params).length === 0,
keys: params && typeof params === "object"
? Object.keys(params)
: "NOT_OBJECT",
keysLength: params && typeof params === "object"
? Object.keys(params).length
: 0,
},
isTargetTool: toolName === "juspay-analytics_SuccessRateSRByTime",
options,
hasExternalManager: !!this.externalServerManager,
});
this.emitter.emit("tool:start", createToolEventPayload(toolName, {
timestamp: executionContext.executionStartTime,
input: params,
executionId: executionContext.executionId,
}));
const toolInfo = this.toolRegistry.getToolInfo(toolName);
const attemptTimeout = options?.timeout ??
toolInfo?.tool?.timeoutMs ??
TOOL_TIMEOUTS.EXECUTION_BATCH_MS;
const maxRetries = options?.maxRetries ??
toolInfo?.tool?.maxRetries ??
RETRY_ATTEMPTS.DEFAULT;
const retryDelayMs = options?.retryDelayMs || RETRY_DELAYS.BASE_MS;
const finalOptions = {
timeout: attemptTimeout,
maxRetries,
// Ceiling on the whole execution, not one attempt. The default is what
// the retry loop already spent, and that is BOTH terms: every attempt at
// full length PLUS the fixed wait between them. Omitting the delays
// makes the default ceiling shorter than the envelope it is meant to
// reproduce, so `attemptTimeout = min(timeout, remaining)` clamps a
// later attempt below its configured timeout — the opposite of the
// "unchanged unless you ask for less" contract this default exists to
// keep. Before any of this the total was unbounded and merely implied:
// a tool that reliably hung burned every attempt at full length, and
// the surfaced error reported the per-attempt bound beside the
// whole-execution elapsed time, which reads as a timeout that was never
// enforced.
totalTimeout: options?.totalTimeoutMs ??
toolInfo?.tool?.totalTimeoutMs ??
attemptTimeout * (maxRetries + 1) + retryDelayMs * maxRetries,
retryDelayMs,
authContext: options?.authContext,
disableToolCache: options?.disableToolCache,
};
const { MemoryManager } = await import("./utils/performance.js");
const startMemory = MemoryManager.getMemoryUsageMB();
const breakerServerId = executionContext.externalTool?.serverId ||
toolInfo?.tool?.serverId ||
"unknown";
const breakerKey = `${breakerServerId}.${toolName}`;
let circuitBreaker = this.toolCircuitBreakers.get(breakerKey);
if (!circuitBreaker) {
circuitBreaker = new CircuitBreaker(CIRCUIT_BREAKER.FAILURE_THRESHOLD, CIRCUIT_BREAKER_RESET_MS);
this.toolCircuitBreakers.set(breakerKey, circuitBreaker);
}
let metrics = this.toolExecutionMetrics.get(toolName);
if (!metrics) {
metrics = {
totalExecutions: 0,
successfulExecutions: 0,
failedExecutions: 0,
averageExecutionTime: 0,
lastExecutionTime: 0,
errorCategories: {},
};
this.toolExecutionMetrics.set(toolName, metrics);
}
metrics.totalExecutions++;
return {
finalOptions,
startMemory,
circuitBreaker,
breakerKey,
metrics,
};
}
async runPreparedToolExecution(toolName, params, prepared, executionContext, toolSpan) {
let toolRetryCount = 0;
try {
mcpLogger.debug(`[${executionContext.functionTag}] Executing tool: ${toolName}`, {
toolName,
params,
options: prepared.finalOptions,
circuitBreakerState: prepared.circuitBreaker.getState(),
});
const maxAttempts = prepared.finalOptions.maxRetries + 1;
const totalTimeout = prepared.finalOptions.totalTimeout;
const budgetStart = Date.now();
const deadline = budgetStart + totalTimeout;
let attemptNumber = 0;
const result = await prepared.circuitBreaker.execute(async () => {
return withRetry(async () => {
attemptNumber++;
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw ErrorFactory.toolTimeout(toolName, totalTimeout, undefined, {
attempt: attemptNumber,
maxAttempts,
attemptTimeoutMs: prepared.finalOptions.timeout,
totalTimeoutMs: totalTimeout,
elapsedMs: Date.now() - budgetStart,
exhausted: true,
});
}
// Clamping to what is left is what makes the ceiling hard. Gating
// retries alone would not: the last attempt could start just under
// the deadline and still run a full attempt timeout past it.
const attemptTimeout = Math.min(prepared.finalOptions.timeout, remaining);
return withTimeout(this.executeToolInternal(toolName, params, prepared.finalOptions, executionContext.hitlState), attemptTimeout, ErrorFactory.toolTimeout(toolName, attemptTimeout, undefined, {
attempt: attemptNumber,
maxAttempts,
attemptTimeoutMs: prepared.finalOptions.timeout,
totalTimeoutMs: totalTimeout,
elapsedMs: Date.now() - budgetStart,
}));
}, {
maxAttempts,
delayMs: prepared.finalOptions.retryDelayMs,
// Stop when there is not enough budget left for the retry delay
// plus any real work after it.
isRetriable: (error) => Date.now() + prepared.finalOptions.retryDelayMs < deadline &&
isRetriableError(error),
onRetry: (attempt, error) => {
toolRetryCount = attempt;
mcpLogger.warn(`[${executionContext.functionTag}] Retrying tool execution (attempt ${attempt})`, {
toolName,
error: error.message,
attempt,
});
},
});
});
toolSpan.setAttribute("tool.retry_count", toolRetryCount);
return await this.handleSuccessfulToolExecution(toolName, result, prepared, executionContext, toolSpan);
}
catch (error) {
// Ensure retry count is recorded even on failure
toolSpan.setAttribute("tool.retry_count", toolRetryCount);
return this.handleFailedToolExecution(toolName, params, error, prepared, executionContext, toolSpan);
}
}
async handleSuccessfulToolExecution(toolName, result, prepared, executionContext, toolSpan) {
const executionTime = Date.now() - executionContext.executionStartTime;
prepared.metrics.successfulExecutions++;
prepared.metrics.lastExecutionTime = executionTime;
prepared.metrics.averageExecutionTime =
(prepared.metrics.averageExecutionTime *
(prepared.metrics.successfulExecutions - 1) +
executionTime) /
prepared.metrics.successfulExecutions;
const { MemoryManager } = await import("./utils/performance.js");
const endMemory = MemoryManager.getMemoryUsageMB();
const memoryDelta = endMemory.heapUsed - prepared.startMemory.heapUsed;
if (memoryDelta > 20) {
mcpLogger.warn(`Tool '${toolName}' used excessive memory: ${memoryDelta}MB`, {
toolName,
memoryDelta,
executionTime,
});
}
mcpLogger.debug(`[${executionContext.functionTag}] Tool executed successfully`, {
toolName,
executionTime,
memoryDelta,
circuitBreakerState: prepared.circuitBreaker.getState(),
});
const resultObj = result && typeof result === "object"
? result
: undefined;
const isToolError = (resultObj && "isError" in resultObj && resultObj.isError === true) ||
(resultObj && "success" in resultObj && resultObj.success === false);
const contentArr = isToolError
? resultObj?.content
: undefined;
const errorText = isToolError
? contentArr
?.filter((content) => content.type === "text" && content.text)
.map((content) => content.text)
.join(" ") ||
(typeof resultObj?.error === "string"
? resultObj.error
: "Unknown error")
: undefined;
if (isToolError) {
try {
await prepared.circuitBreaker.execute(async () => {
throw new Error(`Tool ${toolName} returned isError:true`);
});
}
catch {
// Expected — intentionally records the failure
}
mcpLogger.debug(`[${executionContext.functionTag}] Circuit breaker failure recorded for isError result`, {
toolName,
circuitBreakerState: prepared.circuitBreaker.getState(),
circuitBreakerFailures: prepared.circuitBreaker.getFailureCount(),
});
const errorCategory = classifyMcpErrorMessage(errorText ?? "Unknown error");
const prefix = `[TOOL_ERROR: ${toolName} failed (${errorCategory})] `;
if (resultObj && Array.isArray(contentArr)) {
const clonedContent = contentArr.map((content) => ({ ...content }));
for (const content of clonedContent) {
if (content.type === "text" && content.text) {
content.text = prefix + content.text;
break;
}
}
resultObj.content = clonedContent;
}
toolSpan.setAttribute("tool.error.message", (errorText ?? "Unknown error").substring(0, 500));
toolSpan.setAttribute("tool.error.category", errorCategory);
toolSpan.setStatus({
code: SpanStatusCode.ERROR,
message: `MCP tool returned isError: ${(errorText ?? "Unknown error").substring(0, 200)}`,
});
prepared.metrics.failedExecutions++;
const prevSuccessful = prepared.metrics.successfulExecutions;
prepared.metrics.successfulExecutions = Math.max(0, prepared.metrics.successfulExecutions - 1);
prepared.metrics.averageExecutionTime =
prevSuccessful > 1
? (prepared.metrics.averageExecutionTime * prevSuccessful -
executionTime) /
(prevSuccessful - 1)
: 0;
const mappedCategory = mcpCategoryToErrorCategory(errorCategory);
prepared.metrics.errorCategories[mappedCategory] =
(prepared.metrics.errorCategories[mappedCategory] || 0) + 1;
}
this.emitToolEndEvent(toolName, executionContext.executionStartTime, !isToolError, result, isToolError && errorText ? new Error(errorText) : undefined, executionContext.executionId);
toolSpan.setAttribute("tool.result.status", isToolError ? "error" : "success");
toolSpan.setAttribute("tool.duration_ms", executionTime);
return result;
}
async handleFailedToolExecution(toolName, params, error, prepared, executionContext, toolSpan) {
prepared.metrics.failedExecutions++;
const executionTime = Date.now() - executionContext.executionStartTime;
if (error instanceof CircuitBreakerOpenError) {
mcpLogger.warn(`[${executionContext.functionTag}] Tool blocked by circuit breaker: ${toolName}`, {
toolName,
breakerState: error.breakerState,
retryAfter: error.retryAfter,
retryAfterMs: error.retryAfterMs,
failureCount: error.failureCount,
executionTime,
});
prepared.metrics.errorCategories[ErrorCategory.EXECUTION] =
(prepared.metrics.errorCategories[ErrorCategory.EXECUTION] || 0) + 1;
this.emitToolEndEvent(toolName, executionContext.executionStartTime, false, undefined, new Error(`Circuit breaker open for ${toolName} (state=${error.breakerState}, failures=${error.failureCount})`), executionContext.executionId);
toolSpan.setAttribute("tool.result.status", "circuit_breaker_open");
toolSpan.setAttribute("tool.duration_ms", executionTime);
toolSpan.setAttribute("tool.circuit_breaker.state", error.breakerState);
toolSpan.setAttribute("tool.circuit_breaker.retry_after_ms", error.retryAfterMs);
toolSpan.setAttribute("tool.circuit_breaker.failure_count", error.failureCount);
toolSpan.setStatus({
code: SpanStatusCode.ERROR,
message: `Circuit breaker open for ${toolName}: ${error.message}`,
});
return {
isError: true,
content: [
{
type: "text",
text: `TOOL TEMPORARILY UNAVAILABLE: "${toolName}" has been disabled after ` +
`${error.failureCount} failures. ` +
`This is a circuit breaker protection — do NOT retry this tool. ` +
`It will become available again after ${Math.ceil(error.retryAfterMs / 1000)} seconds ` +
`(at ${error.retryAfter}). ` +
`Instead, inform the user that the operation failed and suggest trying again later.`,
},
],
};
}
let structuredError;
if (error instanceof NeuroLinkError) {
structuredError = error;
}
else if (error instanceof Error) {
if (error.message.includes("timeout")) {
structuredError = ErrorFactory.toolTimeout(toolName, prepared.finalOptions.timeout);
}
else if (error.message.includes("not found")) {
const availableTools = await this.getAllAvailableTools();
structuredError = ErrorFactory.toolNotFound(toolName, extractToolNames(availableTools.map((tool) => ({ name: tool.name }))));
}
else if (error.message.includes("validation") ||
error.message.includes("parameter")) {
structuredError = ErrorFactory.invalidParameters(toolName, error, params);
}
else if (error.message.includes("network") ||
error.message.includes("connection")) {
structuredError = ErrorFactory.networkError(toolName, error);
}
else {
structuredError = ErrorFactory.toolExecutionFailed(toolName, error);
}
}
else {
structuredError = ErrorFactory.toolExecutionFailed(toolName, new Error(String(error)));
}
const category = structuredError.category || ErrorCategory.EXECUTION;
prepared.metrics.errorCategories[category] =
(prepared.metrics.errorCategories[category] || 0) + 1;
this.emitToolEndEvent(toolName, executionContext.executionStartTime, false, undefined, structuredError, executionContext.executionId);
// Gate on listenerCount: Node EventEmitter rethrows the original error
// from emit("error", e) when no listener is registered, which would
// short-circuit the surrounding flow and surface as an unhandled
// rejection. Same pattern as handleGenerateTextInternalFailure.
if (this.emitter.listenerCount("error") > 0) {
this.emitter.emit("error", structuredError);
}
structuredError = new NeuroLinkError({
...structuredError,
context: {
...structuredError.context,
executionTime,
params,
options: prepared.finalOptions,
circuitBreakerState: prepared.circuitBreaker.getState(),
circuitBreakerFailures: prepared.circuitBreaker.getFailureCount(),
metrics: { ...prepared.metrics },
},
});
logStructuredError(structuredError);
toolSpan.setAttribute("tool.result.status", "error");
toolSpan.setAttribute("tool.duration_ms", executionTime);
toolSpan.recordException(structuredError);
toolSpan.setStatus({
code: SpanStatusCode.ERROR,
message: structuredError.message,
});
throw structuredError;
}
/**
* Internal tool execution method with MCP enhancements wired in:
* - ToolCache: check/store cached results for non-destructive tools
* - ToolRouter: route to best server when same tool exists on multiple servers
* - Annotations: skip cache for destructive tools, retry safe tools on failure
* - Middleware: apply global middleware chain before execution
*/
toolCacheRepeatKey(toolName, params) {
try {
return `${toolName}:${JSON.stringify(params) ?? ""}`;
}
catch {
// Unserializable args (circular refs) — no repeat tracking; the
// ToolResultCache's own keying handles (or rejects) them as before.
return undefined;
}
}
async executeToolInternal(toolName, params, options, HITLState) {
const functionTag = "NeuroLink.executeToolInternal";
// === MCP ENHANCEMENT: Infer annotations for cache/retry decisions ===
const toolAnnotations = this.getToolAnnotationsForExecution(toolName);
const isCacheEnabled = this.mcpToolResultCache &&
!options.disableToolCache &&
!this._disableToolCacheForCurrentRequest &&
!this.uncacheableTools.has(toolName) &&
!toolAnnotations?.destructiveHint;
const toolResultCache = this.mcpToolResultCache;
// === MCP ENHANCEMENT: Cache check (before execution) ===
// Scope cache key by auth context to prevent cross-user cache leaks
const cacheParams = options.authContext || this.toolExecutionContext
? {
__args: params,
__ctx: options.authContext ?? this.toolExecutionContext,
}
: params;
const repeatKey = this._generationTurnActive
? this.toolCacheRepeatKey(toolName, cacheParams)
: undefined;
const isRepeatCallThisRequest = repeatKey !== undefined &&
this._toolCacheKeysServedThisRequest.has(repeatKey);
if (repeatKey !== undefined) {
this._toolCacheKeysServedThisRequest.add(repeatKey);
}
if (isCacheEnabled && toolResultCache && !isRepeatCallThisRequest) {
const cached = toolResultCache.getCachedResult(toolName, cacheParams);
if (cached !== undefined) {
logger.debug(`[${functionTag}] Cache HIT for tool: ${toolName}`);
return cached;
}
}
else if (isRepeatCallThisRequest) {
logger.debug(`[${functionTag}] Repeat call within this request — bypassing tool cache for: ${toolName}`);
}
// === MCP ENHANCEMENT: Middleware chain wrapper ===
const executeWithMiddleware = async (executeFn) => {
if (this.mcpToolMiddlewares.length === 0) {
return executeFn();
}
// Build middleware chain: each middleware calls next() to proceed
let index = 0;
const next = async () => {
if (index < this.mcpToolMiddlewares.length) {
const middleware = this.mcpToolMiddlewares[index++];
// Cast to MCPServerTool — middleware only inspects name/description/annotations
const toolStub = {
name: toolName,
description: "",
inputSchema: {},
annotations: toolAnnotations,
execute: async () => ({}),
};
// Provide minimal context — elicitation is optional for most middleware
const middlewareContext = {
toolMeta: { name: toolName, annotations: toolAnnotations },
};
return middleware(toolStub, params, middlewareContext, next);
}
return executeFn();
};
return (await next());
};
// === Execute the tool (with middleware wrapper) ===
const executeCore = async () => {
// Check external MCP servers
const externalTools = this.externalServerManager.getAllTools();
// === MCP ENHANCEMENT: ToolRouter for multi-server routing ===
const matchingTools = externalTools.filter((tool) => tool.name === toolName && tool.isAvailable);
let externalTool;
if (matchingTools.length > 1 && this.mcpToolRouter) {
// Multiple servers have this tool — use router to pick the best one
try {
const mcpTool = {
name: toolName,
description: matchingTools[0].description ?? "",
serverId: matchingTools[0].serverId,
inputSchema: {},
};
const decision = this.mcpToolRouter.route(mcpTool);
externalTool =
matchingTools.find((t) => t.serverId === decision.serverId) ||
matchingTools[0];
logger.debug(`[${functionTag}] Router selected server: ${decision.serverId}`, {
strategy: decision.strategy,
confidence: decision.confidence,
});
}
catch (routerError) {
logger.warn(`[${functionTag}] Router failed, falling back to first match`, { error: routerError });
externalTool = matchingTools[0];
}
}
else {
externalTool = matchingTools[0];
}
logger.debug(`[${functionTag}] External MCP tool search:`, {
toolName,
externalToolsCount: externalTools.length,
foundTool: !!externalTool,
isAvailable: externalTool?.isAvailable,
serverId: externalTool?.serverId,
});
if (externalTool && externalTool.isAvailable) {
try {
mcpLogger.debug(`[${functionTag}] Executing external MCP tool: ${toolName} from ${externalTool.serverId}`);
const result = await this.externalServerManager.executeTool(externalTool.serverId, toolName, params, { timeout: options.timeout });
logger.debug(`[${functionTag}] External MCP tool execution successful:`, {
toolName,
serverId: externalTool.serverId,
resultType: typeof result,
});
return result;
}
catch (error) {
logger.error(`[${functionTag}] External MCP tool execution failed:`, {
toolName,
serverId: externalTool.serverId,
error: error instanceof Error ? error.message : String(error),
});
throw ErrorFactory.toolExecutionFailed(toolName, error instanceof Error ? error : new Error(String(error)), externalTool.serverId);
}
}
// Execute via tool registry (custom/built-in tools)
try {
const storedContext = this.toolExecutionContext || {};
const passedAuthContext = options.authContext || {};
// This instance's roots, on the private channel (overwrites the caller's).
const context = bindFileToolRoots({ ...storedContext, ...passedAuthContext, hitlState: HITLState }, this.resolveFileToolRoots(undefined));
logger.debug(`[Using merged context for unified registry tool:`, {
toolName,
storedContextKeys: Object.keys(storedContext),
finalContextKeys: Object.keys(context),
});
const result = (await this.toolRegistry.executeTool(toolName, params, context));
// Check if result indicates a failure and emit error event
if (result &&
typeof result === "object" &&
"success" in result &&
result.success === false) {
const errorMessage = result.error || "Tool execution failed";
const errorToEmit = new Error(errorMessage);
// Gate on listenerCount — see handleGenerateTextInternalFailure for
// the rationale (Node EventEmitter rethrows on no listener).
if (this.emitter.listenerCount("error") > 0) {
this.emitter.emit("error", errorToEmit);
}
}
return result;
}
catch (error) {
const errorToEmit = error instanceof Error ? error : new Error(String(error));
if (this.emitter.listenerCount("error") > 0) {
this.emitter.emit("error", errorToEmit);
}
// Check if tool was not found
if (error instanceof Error && error.message.includes("not found")) {
const availableTools = await this.getAllAvailableTools();
throw ErrorFactory.toolNotFound(toolName, availableTools.map((t) => t.name));
}
throw ErrorFactory.toolExecutionFailed(toolName, error instanceof Error ? error : new Error(String(error)));
}
};
// Execute with middleware chain
try {
const result = await executeWithMiddleware(executeCore);
// === MCP ENHANCEMENT: Cache store (after successful execution) ===
if (isCacheEnabled && toolResultCache && result !== undefined) {
toolResultCache.cacheResult(toolName, cacheParams, result);
logger.debug(`[${functionTag}] Cached result for tool: ${toolName}`);
}
return result;
}
catch (error) {
// === MCP ENHANCEMENT: Retry safe tools on failure ===
const toolStubForRetry = toolAnnotations
? {
name: toolName,
description: "",
annotations: toolAnnotations,
execute: async () => ({}),
}
: undefined;
if (toolStubForRetry &&
isSafeToRetry(toolStubForRetry) &&
error instanceof Error &&
isRetriableError(error)) {
logger.debug(`[${functionTag}] Tool ${toolName} is safe to retry, attempting once more`);
try {
const retryResult = await executeWithMiddleware(executeCore);
// Cache the retry result
if (isCacheEnabled && toolResultCache && retryResult !== undefined) {
toolResultCache.cacheResult(toolName, cacheParams, retryResult);
}
return retryResult;
}
catch {
// Retry failed, throw original error
}
}
throw error;
}
}
/**
* Get tool annotations for execution decisions (cache, retry).
* Checks cached tool list first, falls back to inference from tool name.
*/
getToolAnnotationsForExecution(toolName) {
// Check tool cache for stored annotations
if (this.toolCache?.tools) {
const tool = this.toolCache.tools.find((t) => t.name === toolName);
if (tool?.annotations) {
return tool.annotations;
}
}
// Fallback: infer from tool name if annotations are enabled
if (this.mcpEnhancementsConfig?.annotations?.autoInfer !== false) {
return inferAnnotations({ name: toolName, description: "" });
}
return undefined;
}
/**
* Get all available tools including custom and in-memory ones
* @returns Array of available tools with metadata
*/
invalidateToolCache() {
this.toolCache = null;
logger.debug("Tool cache invalidated");
}
async getAllAvailableTools() {
// Return from cache if available and not stale
if (this.toolCache &&
Date.now() - this.toolCache.timestamp < this.toolCacheDuration) {
logger.debug("Returning available tools from cache");
return this.toolCache.tools;
}
// 🚀 EXHAUSTIVE LOGGING POINT A001: GET ALL AVAILABLE TOOLS ENTRY
const getAllToolsId = `get-all-tools-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const getAllToolsStartTime = Date.now();
const getAllToolsHrTimeStart = process.hrtime.bigint();
logger.debug(`[NeuroLink] 🛠️ LOG_POINT_A001_GET_ALL_TOOLS_START`, {
logPoint: "A001_GET_ALL_TOOLS_START",
getAllToolsId,
timestamp: new Date().toISOString(),
getAllToolsStartTime,
getAllToolsHrTimeStart: getAllToolsHrTimeStart.toString(),
// 🔧 Tool registry state
toolRegistryState: {
hasToolRegistry: !!this.toolRegistry,
toolRegistrySize: 0, // Not accessible as size property
toolRegistryType: this.toolRegistry?.constructor?.name || "NOT_SET",
hasExternalServerManager: !!this.externalServerManager,
externalServerManagerType: this.externalServerManager?.constructor?.name || "NOT_SET",
},
// 🌐 MCP state
mcpState: {
mcpInitialized: this.mcpInitialized,
hasProviderRegistry: !!AIProviderFactory,
providerRegistrySize: 0, // Not accessible as size property
},
message: "Starting comprehensive tool discovery across all sources",
});
// Track memory usage for tool listing operations
const { MemoryManager } = await import("./utils/performance.js");
const startMemory = MemoryManager.getMemoryUsageMB();
try {
// Optimized: Collect all tools with minimal object creation
const allTools = new Map();
// 1. Add MCP server tools (built-in direct tools)
const mcpToolsRaw = await this.toolRegistry.listTools();
for (const tool of mcpToolsRaw) {
if (!allTools.has(tool.name)) {
const optimizedTool = optimizeToolForCollection(tool, {
serverId: tool.serverId === "direct" ? "neurolink-direct" : tool.serverId,
});
allTools.set(tool.name, optimizedTool);
}
}
// 2. Add custom tools from this NeuroLink instance
const customToolsRaw = this.toolRegistry.getToolsByCategory(detectCategory({ isCustomTool: true }));
for (const tool of customToolsRaw) {
if (!allTools.has(tool.name)) {
const optimizedTool = optimizeToolForCollection(tool, {
description: "Custom tool",
serverId: `custom-tool-${tool.name}`,
category: detectCategory({
isCustomTool: true,
serverId: tool.serverId,
}),
inputSchema: {},
});
allTools.set(tool.name, optimizedTool);
}
}
// 3. Add tools from in-memory MCP servers
const inMemoryToolsRaw = this.toolRegistry.getToolsByCategory("in-memory");
for (const tool of inMemoryToolsRaw) {
if (!allTools.has(tool.name)) {
const optimizedTool = optimizeToolForCollection(tool, {
description: "In-memory MCP tool",
serverId: "unknown",
category: "in-memory",
inputSchema: {},
});
allTools.set(tool.name, optimizedTool);
}
}
// 4. Add external MCP tools
const externalMCPToolsRaw = this.externalServerManager.getAllTools();
for (const tool of externalMCPToolsRaw) {
if (!allTools.has(tool.name)) {
const optimizedTool = optimizeToolForCollection(tool, {
category: detectCategory({
existingCategory: typeof tool.metadata?.category === "string"
? tool.metadata.category
: undefined,
isExternal: true,
serverId: tool.serverId,
}),
inputSchema: {},
});
allTools.set(tool.name, optimizedTool);
}
}
const uniqueTools = Array.from(allTools.values());
mcpLogger.debug("Tool discovery results", {
mcpTools: mcpToolsRaw.length,
customTools: customToolsRaw.length,
inMemoryTools: inMemoryToolsRaw.length,
externalMCPTools: externalMCPToolsRaw.length,
total: uniqueTools.length,
});
// Check memory usage after tool enumeration
const endMemory = MemoryManager.getMemoryUsageMB();
const memoryDelta = endMemory.heapUsed - startMemory.heapUsed;
if (memoryDelta > MEMORY_THRESHOLDS.LOW_USAGE_MB) {
mcpLogger.debug(`🔍 Tool listing used ${memoryDelta}MB memory (large tool registry detected)`);
// Optimized collection patterns should reduce memory usage significantly
if (uniqueTools.length > PERFORMANCE_THRESHOLDS.LARGE_TOOL_COLLECTION) {
mcpLogger.debug("💡 Tool collection optimized for large sets. Memory usage reduced through efficient object reuse.");
}
}
// === MCP ENHANCEMENT: Auto-infer annotations for all tools ===
if (this.mcpEnhancementsConfig?.annotations?.autoInfer !== false) {
for (const tool of uniqueTools) {
if (!tool.annotations) {
tool.annotations = inferAnnotations({
name: tool.name,
description: tool.description || "",
});
}
}
}
// Return canonical ToolInfo[]; defer presentation transforms to call sites.
// Name-sorted: external MCP discovery completes in parallel, so Map
// insertion order varies across restarts — downstream consumers (tool
// listings, prompt construction, budget accounting) need a stable order.
const tools = uniqueTools.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
// Update the cache
this.toolCache = {
tools,
timestamp: Date.now(),
};
return tools;
}
catch (error) {
mcpLogger.error("Failed to list available tools", { error });
return [];
}
}
// ============================================================================
// PROVIDER DIAGNOSTICS - SDK-First Architecture
// ============================================================================
/**
* Get comprehensive status of all AI providers
* Primary method for provider health checking and diagnostics
*/
async getProviderStatus(options) {
// Track memory and timing for provider status checks
const { MemoryManager } = await import("./utils/performance.js");
const startMemory = MemoryManager.getMemoryUsageMB();
// Ensure providers are registered before testing
if (!options?.quiet) {
mcpLogger.debug("🔍 DEBUG: Initializing MCP for provider status...");
}
await this.initializeMCP();
if (!options?.quiet) {
mcpLogger.debug("🔍 DEBUG: MCP initialized:", this.mcpInitialized);
}
const { AIProviderFactory } = await import("./core/factory.js");
const { hasProviderEnvVars } = await import("./utils/providerUtils.js");
// Keep references to prevent unused variable warnings
void AIProviderFactory;
void hasProviderEnvVars;
// Derived from the descriptor registry instead of a hand-maintained
// list — covers all real providers (was previously hardcoded to 11,
// including "googleVertex" as a duplicate entry alongside "vertex";
// that alias remains resolvable via PROVIDER_ALIAS_INDEX/CLI choices,
// it just no longer gets its own separate status-check entry).
const providers = ProviderFactory.getAllDescriptors().map((d) => d.name);
// Test providers with controlled concurrency
// This reduces total time from 16s (sequential) to ~3s (parallel) while preventing resource exhaustion
const limit = pLimit(SYSTEM_LIMITS.DEFAULT_CONCURRENCY_LIMIT);
const providerTests = providers.map((providerName) => limit(async () => {
const startTime = Date.now();
try {
// Check if provider has required environment variables
const hasEnvVars = await this.hasProviderEnvVars(providerName);
if (!hasEnvVars && providerName !== "ollama") {
return {
provider: providerName,
status: "not-configured",
configured: false,
authenticated: false,
error: "Missing required environment variables",
responseTime: Date.now() - startTime,
};
}
// Special handling for Ollama
if (providerName === "ollama") {
try {
const response = await fetch("http://localhost:11434/api/tags", {
method: "GET",
signal: AbortSignal.timeout(PROVIDER_TIMEOUTS.AUTH_MS),
});
if (!response.ok) {
throw new Error("Ollama service not responding");
}
const responseData = await response.json();
const models = responseData?.models;
// Runtime-safe guard: ensure models is an array with valid objects
if (!Array.isArray(models)) {
logger.warn("Ollama API returned invalid models format in testProvider", {
responseData,
modelsType: typeof models,
});
throw new Error("Invalid models format from Ollama API");
}
// Filter and validate models before comparison
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
if (validModels.length > 0) {
return {
provider: providerName,
status: "working",
configured: true,
authenticated: true,
responseTime: Date.now() - startTime,
model: validModels[0].name,
};
}
else {
return {
provider: providerName,
status: "failed",
configured: true,
authenticated: false,
error: "Ollama service running but no models installed",
responseTime: Date.now() - startTime,
};
}
}
catch (error) {
return {
provider: providerName,
status: "failed",
configured: false,
authenticated: false,
error: error instanceof Error
? error.message
: "Ollama service not running",
responseTime: Date.now() - startTime,
};
}
}
// Test other providers with actual generation call. The losing
// side of the race is aborted in `finally` below — previously the
// timeout promise alone rejected while the still-running
// testProviderConnection() call (and the provider request inside
// it) kept executing to completion unobserved, wasting quota and
// holding the connection open past the 5s status check.
const testTimeout = 5000;
const abortController = new AbortController();
const testPromise = this.testProviderConnection(providerName, abortController.signal);
let timeoutHandle;
const timeoutPromise = new Promise((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error("Provider test timeout (5s)"));
}, testTimeout);
});
try {
await Promise.race([testPromise, timeoutPromise]);
}
finally {
// Runs on both outcomes: clears the timer so it can't fire after
// a fast success, and aborts the signal so a timed-out
// generate() call stops instead of finishing unobserved.
clearTimeout(timeoutHandle);
// The aborted generate() resolves/rejects after the race is
// decided; swallow that settlement so it can't surface as an
// unhandledRejection, mirroring raceWithAbort's handling of the
// losing side.
testPromise.catch(() => undefined);
abortController.abort();
}
return {
provider: providerName,
status: "working",
configured: true,
authenticated: true,
responseTime: Date.now() - startTime,
};
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
provider: providerName,
status: "failed",
configured: true,
authenticated: false,
error: errorMessage,
responseTime: Date.now() - startTime,
};
}
}));
// Wait for all provider tests to complete in parallel
const results = await Promise.all(providerTests);
// Track memory usage and suggest cleanup if needed
const endMemory = MemoryManager.getMemoryUsageMB();
const memoryDelta = endMemory.heapUsed - startMemory.heapUsed;
if (!options?.quiet && memoryDelta > 20) {
mcpLogger.debug(`🔍 Memory usage: +${memoryDelta}MB (consider cleanup for large operations)`);
}
// Suggest garbage collection for large memory increases
if (memoryDelta > 50) {
MemoryManager.forceGC();
}
return results;
}
/**
* Test a specific AI provider's connectivity and authentication
* @param providerName - Name of the provider to test
* @returns Promise resolving to true if provider is working
*/
async testProvider(providerName) {
try {
await this.testProviderConnection(providerName);
return true;
}
catch {
return false;
}
}
/**
* Internal method to test provider connection with minimal generation call.
* `signal` lets a caller (getProviderStatus's per-provider race) cancel the
* underlying request once it has already lost the race against its
* timeout, instead of leaving it running unobserved.
*/
async testProviderConnection(providerName, signal) {
const { AIProviderFactory } = await import("./core/factory.js");
const provider = await AIProviderFactory.createProvider(providerName, null);
await provider.generate({
prompt: "test",
maxTokens: 1,
disableTools: true,
abortSignal: signal,
});
}
/**
* Get the best available AI provider based on configuration and availability
* @param requestedProvider - Optional preferred provider name
* @returns Promise resolving to the best provider name
*/
async getBestProvider(requestedProvider) {
const { getBestProvider } = await import("./utils/providerUtils.js");
return getBestProvider(requestedProvider);
}
/**
* Get list of all available AI provider names
* @returns Array of supported provider names
*/
async getAvailableProviders() {
const { getAvailableProviders } = await import("./utils/providerUtils.js");
return getAvailableProviders();
}
/**
* Validate if a provider name is supported
* @param providerName - Provider name to validate
* @returns True if provider name is valid
*/
async isValidProvider(providerName) {
const { isValidProvider } = await import("./utils/providerUtils.js");
return isValidProvider(providerName);
}
// ============================================================================
// MCP DIAGNOSTICS - SDK-First Architecture
// ============================================================================
/**
* Get comprehensive MCP (Model Context Protocol) status information
* @returns Promise resolving to MCP status details
*/
async getMCPStatus() {
try {
// Initialize MCP if not already initialized (loads external servers from config)
await this.initializeMCP();
// Get built-in tools
const allTools = await this.toolRegistry.listTools();
// Get external MCP server statistics
const externalStats = this.externalServerManager.getStatistics();
// DIRECT RETURNS - ZERO conversion
const externalMCPServers = this.externalServerManager.listServers();
const inMemoryServerInfos = this.getInMemoryServerInfos();
const builtInServerInfos = this.toolRegistry.getBuiltInServerInfos();
const autoDiscoveredServerInfos = this.getAutoDiscoveredServerInfos();
// Calculate totals
const totalServers = externalMCPServers.length +
inMemoryServerInfos.length +
builtInServerInfos.length +
autoDiscoveredServerInfos.length;
const availableServers = externalStats.connectedServers +
inMemoryServerInfos.length +
builtInServerInfos.length; // in-memory and built-in always available
const totalTools = allTools.length + externalStats.totalTools;
return {
mcpInitialized: this.mcpInitialized,
totalServers,
availableServers,
autoDiscoveredCount: autoDiscoveredServerInfos.length,
totalTools,
autoDiscoveredServers: autoDiscoveredServerInfos,
customToolsCount: this.toolRegistry.getToolsByCategory(detectCategory({ isCustomTool: true })).length,
inMemoryServersCount: inMemoryServerInfos.length,
externalMCPServersCount: externalMCPServers.length,
externalMCPConnectedCount: externalStats.connectedServers,
externalMCPFailedCount: externalStats.failedServers,
externalMCPServers,
};
}
catch (error) {
return {
mcpInitialized: false,
totalServers: 0,
availableServers: 0,
autoDiscoveredCount: 0,
totalTools: 0,
autoDiscoveredServers: [],
customToolsCount: this.toolRegistry.getToolsByCategory(detectCategory({ isCustomTool: true })).length,
inMemoryServersCount: 0,
externalMCPServersCount: 0,
externalMCPConnectedCount: 0,
externalMCPFailedCount: 0,
externalMCPServers: [],
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* List all configured MCP servers with their status
* @returns Promise resolving to array of MCP server information
*/
async listMCPServers() {
// DIRECT RETURNS - ZERO conversion logic
return [
...this.externalServerManager.listServers(), // Direct return
...this.getInMemoryServerInfos(), // Direct return
...this.toolRegistry.getBuiltInServerInfos(), // Direct return
...this.getAutoDiscoveredServerInfos(), // Direct return
];
}
/**
* Test connectivity to a specific MCP server
* @param serverId - ID of the MCP server to test
* @returns Promise resolving to true if server is reachable
*/
async testMCPServer(serverId) {
try {
// Test built-in tools
if (serverId === "neurolink-direct") {
const tools = await this.toolRegistry.listTools();
return tools.length > 0;
}
// Test in-memory servers
const inMemoryServers = this.getInMemoryServers();
if (inMemoryServers.has(serverId)) {
const serverInfo = inMemoryServers.get(serverId);
return !!(serverInfo?.tools && serverInfo.tools.length > 0);
}
// Test external MCP servers
const externalServer = this.externalServerManager.getServer(serverId);
if (externalServer) {
return (externalServer.status === "connected" &&
externalServer.client !== null);
}
return false;
}
catch (error) {
mcpLogger.error(`[NeuroLink] Error testing MCP server ${serverId}:`, error);
return false;
}
}
// ==================== PROVIDER HEALTH CHECKING ====================
/**
* Check if a provider has the required environment variables configured
* @param providerName - Name of the provider to check
* @returns Promise resolving to true if provider has required env vars
*/
async hasProviderEnvVars(providerName) {
const { ProviderHealthChecker } = await import("./utils/providerHealth.js");
try {
const health = await ProviderHealthChecker.checkProviderHealth(providerName, {
includeConnectivityTest: false,
cacheResults: false,
});
return health.isConfigured && health.hasApiKey;
}
catch (error) {
logger.warn(`Provider env var check failed for ${providerName}`, {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
/**
* Perform comprehensive health check on a specific provider
* @param providerName - Name of the provider to check
* @param options - Health check options
* @returns Promise resolving to detailed health status
*/
async checkProviderHealth(providerName, options = {}) {
const { ProviderHealthChecker } = await import("./utils/providerHealth.js");
const health = await ProviderHealthChecker.checkProviderHealth(providerName, options);
return {
provider: health.provider,
isHealthy: health.isHealthy,
isConfigured: health.isConfigured,
hasApiKey: health.hasApiKey,
lastChecked: health.lastChecked,
error: health.error,
warning: health.warning,
responseTime: health.responseTime,
configurationIssues: health.configurationIssues,
recommendations: health.recommendations,
};
}
/**
* Check health of all supported providers
* @param options - Health check options
* @returns Promise resolving to array of health statuses for all providers
*/
async checkAllProvidersHealth(options = {}) {
const { ProviderHealthChecker } = await import("./utils/providerHealth.js");
const healthStatuses = await ProviderHealthChecker.checkAllProvidersHealth(options);
return healthStatuses.map((health) => ({
provider: health.provider,
isHealthy: health.isHealthy,
isConfigured: health.isConfigured,
hasApiKey: health.hasApiKey,
lastChecked: health.lastChecked,
error: health.error,
warning: health.warning,
responseTime: health.responseTime,
configurationIssues: health.configurationIssues,
recommendations: health.recommendations,
}));
}
/**
* Get a summary of provider health across all supported providers
* @returns Promise resolving to health summary statistics
*/
async getProviderHealthSummary() {
const { ProviderHealthChecker } = await import("./utils/providerHealth.js");
const healthStatuses = await ProviderHealthChecker.checkAllProvidersHealth({
cacheResults: true,
includeConnectivityTest: false,
});
const summary = ProviderHealthChecker.getHealthSummary(healthStatuses);
// Add recommendations based on the overall health
const recommendations = [];
if (summary.healthy === 0) {
recommendations.push("No providers are healthy. Check your environment configuration.");
}
else if (summary.healthy < 2) {
recommendations.push("Consider configuring additional providers for better reliability.");
}
if (summary.hasIssues > 0) {
recommendations.push("Some providers have configuration issues. Run checkAllProvidersHealth() for details.");
}
return {
...summary,
recommendations,
};
}
/**
* Clear provider health cache (useful for re-testing after configuration changes)
* @param providerName - Optional specific provider to clear cache for
*/
async clearProviderHealthCache(providerName) {
const { ProviderHealthChecker } = await import("./utils/providerHealth.js");
ProviderHealthChecker.clearHealthCache(providerName);
}
// ==================== TOOL EXECUTION DIAGNOSTICS ====================
/**
* Get execution metrics for all tools
* @returns Object with execution metrics for each tool
*/
getToolExecutionMetrics() {
const metrics = {};
for (const [toolName, toolMetrics] of this.toolExecutionMetrics.entries()) {
metrics[toolName] = {
...toolMetrics,
errorCategories: { ...toolMetrics.errorCategories },
successRate: toolMetrics.totalExecutions > 0
? toolMetrics.successfulExecutions / toolMetrics.totalExecutions
: 0,
};
}
return metrics;
}
/**
* NL-004: Set model alias/deprecation configuration.
* Models in the alias map will be warned, redirected, or blocked based on their action.
* @param config - Model alias configuration with aliases map
*/
setModelAliasConfig(config) {
this.modelAliasConfig = config;
logger.info(`[ModelAlias] Configured ${Object.keys(config.aliases).length} model aliases`);
}
/**
* Get circuit breaker status for all tools
* @returns Object with circuit breaker status for each tool
*/
getToolCircuitBreakerStatus() {
const status = {};
for (const [toolName, circuitBreaker,] of this.toolCircuitBreakers.entries()) {
status[toolName] = {
state: circuitBreaker.getState(),
failureCount: circuitBreaker.getFailureCount(),
isHealthy: circuitBreaker.getState() === "closed",
};
}
return status;
}
/**
* Reset circuit breaker for a specific tool
* @param toolName - Name of the tool to reset circuit breaker for
*/
resetToolCircuitBreaker(toolName) {
if (this.toolCircuitBreakers.has(toolName)) {
// Create a new circuit breaker (effectively resets it)
this.toolCircuitBreakers.set(toolName, new CircuitBreaker(CIRCUIT_BREAKER.FAILURE_THRESHOLD, CIRCUIT_BREAKER_RESET_MS));
mcpLogger.info(`Circuit breaker reset for tool: ${toolName}`);
}
}
/**
* Clear all tool execution metrics
*/
clearToolExecutionMetrics() {
this.toolExecutionMetrics.clear();
mcpLogger.info("All tool execution metrics cleared");
}
/**
* Get comprehensive tool health report
* @returns Detailed health report for all tools
*/
async getToolHealthReport() {
const tools = {};
let healthyCount = 0;
// Get all tool names from toolRegistry
const allTools = await this.toolRegistry.listTools();
const allToolNames = new Set(allTools.map((tool) => tool.name));
// Build a lookup from tool name to serverId for composite breaker keys
const toolServerIdMap = new Map();
for (const tool of allTools) {
if (!toolServerIdMap.has(tool.name)) {
toolServerIdMap.set(tool.name, tool.serverId || "unknown");
}
}
for (const toolName of allToolNames) {
const metrics = this.toolExecutionMetrics.get(toolName);
const breakerKey = `${toolServerIdMap.get(toolName) || "unknown"}.${toolName}`;
const circuitBreaker = this.toolCircuitBreakers.get(breakerKey);
const successRate = metrics
? metrics.totalExecutions > 0
? metrics.successfulExecutions / metrics.totalExecutions
: 0
: 0;
const isHealthy = (!circuitBreaker || circuitBreaker.getState() === "closed") &&
successRate >= 0.8;
if (isHealthy) {
healthyCount++;
}
const issues = [];
const recommendations = [];
if (circuitBreaker && circuitBreaker.getState() === "open") {
issues.push("Circuit breaker is open due to repeated failures");
recommendations.push("Check tool implementation and fix underlying issues");
}
if (successRate < 0.8 && metrics && metrics.totalExecutions > 0) {
issues.push(`Low success rate: ${(successRate * 100).toFixed(1)}%`);
recommendations.push("Review error logs and improve tool reliability");
}
if (metrics && metrics.averageExecutionTime > 10000) {
issues.push("High average execution time");
recommendations.push("Optimize tool performance or increase timeout");
}
if (metrics && metrics.errorCategories) {
const categories = metrics.errorCategories;
if (categories[ErrorCategory.TIMEOUT] > 0) {
issues.push(`Timeout errors: ${categories[ErrorCategory.TIMEOUT]}`);
recommendations.push("Consider increasing the tool timeout configuration");
}
if (categories[ErrorCategory.VALIDATION] > 0) {
issues.push(`Validation errors: ${categories[ErrorCategory.VALIDATION]}`);
recommendations.push("Review input schemas and parameter validation");
}
if (categories[ErrorCategory.NETWORK] > 0) {
issues.push(`Network errors: ${categories[ErrorCategory.NETWORK]}`);
recommendations.push("Check network connectivity and endpoint availability");
}
}
tools[toolName] = {
name: toolName,
isHealthy,
metrics: {
totalExecutions: metrics?.totalExecutions || 0,
successRate,
averageExecutionTime: metrics?.averageExecutionTime || 0,
lastExecutionTime: metrics?.lastExecutionTime || 0,
errorCategories: metrics?.errorCategories
? { ...metrics.errorCategories }
: {},
},
circuitBreaker: {
state: circuitBreaker?.getState() || "closed",
failureCount: circuitBreaker?.getFailureCount() || 0,
},
issues,
recommendations,
};
}
return {
totalTools: allToolNames.size,
healthyTools: healthyCount,
unhealthyTools: allToolNames.size - healthyCount,
tools,
};
}
// ============================================================================
// CONVERSATION MEMORY PUBLIC API
// ============================================================================
/**
* Initialize conversation memory if enabled (public method for explicit initialization)
* This is useful for testing or when you want to ensure conversation memory is ready
* @returns Promise resolving to true if initialization was successful, false otherwise
*/
async ensureConversationMemoryInitialized() {
try {
const initId = `manual-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
return !!this.conversationMemory;
}
catch (error) {
logger.error("Failed to initialize conversation memory", {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
/**
* Get conversation memory statistics (public API)
*/
async getConversationStats() {
// First ensure memory is initialized
const initId = `stats-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new NeuroLinkError({
code: ERROR_CODES.MISSING_CONFIGURATION,
message: "Conversation memory is not enabled",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
return await this.conversationMemory.getStats();
}
/**
* Get complete conversation history for a specific session (public API)
* @param sessionId - The session ID to retrieve history for
* @returns Array of ChatMessage objects in chronological order, or empty array if session doesn't exist
*/
async getConversationHistory(sessionId) {
// First ensure memory is initialized
const initId = `history-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new NeuroLinkError({
code: ERROR_CODES.MISSING_CONFIGURATION,
message: "Conversation memory is not enabled",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
if (!sessionId || typeof sessionId !== "string") {
throw new NeuroLinkError({
code: ERROR_CODES.INVALID_PARAMETERS,
message: "Session ID must be a non-empty string",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
context: { sessionId },
});
}
try {
// Use the existing buildContextMessages method to get the complete history
const messages = await this.conversationMemory.buildContextMessages(sessionId);
logger.debug("Retrieved conversation history", {
sessionId,
messageCount: messages.length,
turnCount: messages.length / 2, // Each turn = user + assistant message
});
return messages;
}
catch (error) {
logger.error("Failed to retrieve conversation history", {
sessionId,
error: error instanceof Error ? error.message : String(error),
});
// Return empty array for graceful handling of missing sessions
return [];
}
}
/**
* Clear conversation history for a specific session (public API)
*/
async clearConversationSession(sessionId) {
// First ensure memory is initialized
const initId = `clear-session-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new NeuroLinkError({
code: ERROR_CODES.MISSING_CONFIGURATION,
message: "Conversation memory is not enabled",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
this.lastCompactionMessageCount.delete(sessionId);
return await this.conversationMemory.clearSession(sessionId);
}
/**
* Clear all conversation history (public API)
*/
async clearAllConversations() {
// First ensure memory is initialized
const initId = `clear-all-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new NeuroLinkError({
code: ERROR_CODES.MISSING_CONFIGURATION,
message: "Conversation memory is not enabled",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
this.lastCompactionMessageCount.clear();
await this.conversationMemory.clearAllSessions();
}
/**
* List all conversation sessions with metadata (public API)
* @param userId - Optional user ID to filter sessions (required for Redis storage)
* @returns Array of session list items with metadata
*/
async listSessions(userId) {
// First ensure memory is initialized
const initId = `list-sessions-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new Error("Conversation memory is not enabled");
}
// Check if listSessions is available on the memory manager
if (!this.conversationMemory.listSessions) {
logger.warn("listSessions not available on current memory manager");
return [];
}
const MEMORY_OPERATION_TIMEOUT = 30000; // 30 seconds
try {
const sessions = await withTimeout(this.conversationMemory.listSessions(userId), MEMORY_OPERATION_TIMEOUT, new Error("listSessions operation timed out after 30s"));
logger.debug("Listed conversation sessions", {
userId,
sessionCount: sessions.length,
});
return sessions;
}
catch (error) {
logger.error("Failed to list conversation sessions", {
userId,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Export a single session with full history and metadata (public API)
* @param sessionId - The session ID to export
* @param options - Export options
* @returns Session export object with full history
*/
async exportSession(sessionId, options = {}) {
// First ensure memory is initialized
const initId = `export-session-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new Error("Conversation memory is not enabled");
}
if (!sessionId || typeof sessionId !== "string") {
throw new Error("Session ID must be a non-empty string");
}
const MEMORY_OPERATION_TIMEOUT = 30000; // 30 seconds
try {
const messages = await withTimeout(this.conversationMemory.buildContextMessages(sessionId), MEMORY_OPERATION_TIMEOUT, new Error("buildContextMessages operation timed out after 30s"));
if (messages.length === 0) {
logger.debug("No messages found for session export", { sessionId });
return null;
}
const sessionResult = this.conversationMemory.getSession(sessionId);
const session = await withTimeout(sessionResult instanceof Promise
? sessionResult
: Promise.resolve(sessionResult), MEMORY_OPERATION_TIMEOUT, new Error("getSession operation timed out after 30s"));
const now = new Date().toISOString();
const exportData = {
sessionId,
title: sessionId, // Use sessionId as title if not available
userId: session?.userId,
createdAt: session?.createdAt
? new Date(session.createdAt).toISOString()
: now,
updatedAt: session?.lastActivity
? new Date(session.lastActivity).toISOString()
: now,
messages,
};
if (options.includeMetadata) {
exportData.exportMetadata = {
exportedAt: now,
exportFormat: options.format || "json",
};
}
logger.debug("Exported conversation session", {
sessionId,
messageCount: messages.length,
});
return exportData;
}
catch (error) {
logger.error("Failed to export conversation session", {
sessionId,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Export all sessions for a user (public API)
* @param userId - Optional user ID (required for Redis storage)
* @param options - Export options
* @returns Array of session exports
*/
async exportAllSessions(userId, options = {}) {
// First ensure memory is initialized
const initId = `export-all-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new Error("Conversation memory is not enabled");
}
const MEMORY_OPERATION_TIMEOUT = 30000; // 30 seconds
const EXPORT_SESSION_TIMEOUT = 60000; // 60 seconds for full export
try {
// Get all session IDs
const sessions = await withTimeout(this.listSessions(userId), MEMORY_OPERATION_TIMEOUT, new Error("listSessions operation timed out after 30s"));
const exports = [];
for (const session of sessions) {
const exportData = await withTimeout(this.exportSession(session.id, options), EXPORT_SESSION_TIMEOUT, new Error(`exportSession operation timed out after 60s for session ${session.id}`));
if (exportData) {
exports.push(exportData);
}
}
logger.debug("Exported all conversation sessions", {
userId,
sessionCount: exports.length,
});
return exports;
}
catch (error) {
logger.error("Failed to export all conversation sessions", {
userId,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Store tool executions in conversation memory if enabled and Redis is configured
* @param sessionId - Session identifier
* @param userId - User identifier (optional)
* @param toolCalls - Array of tool calls
* @param toolResults - Array of tool results
* @param currentTime - Date when the tool execution occurred (optional)
* @returns Promise resolving when storage is complete
*/
async storeToolExecutions(sessionId, userId, toolCalls, toolResults, currentTime) {
// Check if tools are not empty
const hasToolData = (toolCalls && toolCalls.length > 0) ||
(toolResults && toolResults.length > 0);
if (!hasToolData) {
logger.debug("Tool execution storage skipped", {
hasToolData,
toolCallsCount: toolCalls?.length || 0,
toolResultsCount: toolResults?.length || 0,
});
return;
}
// Any backend that implements storeToolExecution — no longer a Redis cast.
// The in-memory manager implements it too, so tool activity becomes
// tool_call/tool_result messages regardless of STORAGE_TYPE.
const memory = this.conversationMemory;
if (!memory?.storeToolExecution) {
logger.debug("Tool execution storage not supported by this memory backend");
return;
}
try {
await memory.storeToolExecution(sessionId, userId, toolCalls, toolResults, currentTime);
}
catch (error) {
logger.warn("Failed to store tool executions", {
sessionId,
userId,
error: error instanceof Error ? error.message : String(error),
});
// Don't throw - tool storage failures shouldn't break generation
}
}
/**
* Check if tool execution storage is available.
*
* Now capability-based rather than Redis-specific: any configured memory
* backend implementing `storeToolExecution` qualifies. The old check
* required `STORAGE_TYPE === "redis"` AND a Redis manager by class name, so
* in-memory sessions reported false and silently skipped tool persistence.
*
* @returns whether the active memory backend can persist tool executions
*/
isToolExecutionStorageAvailable() {
return typeof this.conversationMemory?.storeToolExecution === "function";
}
/**
* Get the raw messages array for a session.
* Returns the full messages list without context filtering or summarization.
* @param sessionId - The session ID to retrieve messages for
* @returns Array of ChatMessage objects, or empty array if session doesn't exist
*/
async getSessionMessages(sessionId, userId) {
const initId = `get-msgs-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new NeuroLinkError({
code: ERROR_CODES.MISSING_CONFIGURATION,
message: "Conversation memory is not enabled",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
if (!sessionId || typeof sessionId !== "string") {
throw new NeuroLinkError({
code: ERROR_CODES.INVALID_PARAMETERS,
message: "Session ID must be a non-empty string",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
context: { sessionId },
});
}
return await this.conversationMemory.getSessionMessages(sessionId, userId);
}
/**
* Replace the entire messages array for a session.
* @param sessionId - The session ID to update
* @param messages - The new messages array
* @param userId - Optional user ID for scoped Redis key lookup
*/
async setSessionMessages(sessionId, messages, userId) {
const initId = `set-msgs-init-${Date.now()}`;
await this.initializeConversationMemoryForGeneration(initId, Date.now(), process.hrtime.bigint());
if (!this.conversationMemory) {
throw new NeuroLinkError({
code: ERROR_CODES.MISSING_CONFIGURATION,
message: "Conversation memory is not enabled",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
if (!sessionId || typeof sessionId !== "string") {
throw new NeuroLinkError({
code: ERROR_CODES.INVALID_PARAMETERS,
message: "Session ID must be a non-empty string",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
context: { sessionId },
});
}
await this.conversationMemory.setSessionMessages(sessionId, messages, userId);
}
/**
* Modify the last assistant message in a session using a transformer function.
* Convenience wrapper around getSessionMessages/setSessionMessages.
* @param sessionId - The session ID to modify
* @param transformer - Function that receives the last assistant message content and returns the modified content
* @param userId - Optional user ID for scoped Redis key lookup
* @returns true if a message was modified, false if no assistant message was found
*/
async modifyLastAssistantMessage(sessionId, transformer, userId) {
const messages = await this.getSessionMessages(sessionId, userId);
// Find the last assistant message (searching from the end)
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
messages[i] = {
...messages[i],
content: transformer(messages[i].content),
};
await this.setSessionMessages(sessionId, messages, userId);
return true;
}
}
return false;
}
// ===== EXTERNAL MCP SERVER METHODS =====
/**
* Add an external MCP server
* Automatically discovers and registers tools from the server
* @param serverId - Unique identifier for the server
* @param config - External MCP server configuration
* @returns Operation result with server instance
*/
async addExternalMCPServer(serverId, config) {
this.invalidateToolCache(); // Invalidate cache when an external server is added
try {
mcpLogger.info(`[NeuroLink] Adding external MCP server: ${serverId}`, {
command: config.command,
transport: config.transport,
});
const result = await this.externalServerManager.addServer(serverId, config);
if (result.success) {
mcpLogger.info(`[NeuroLink] External MCP server added successfully: ${serverId}`, {
toolsDiscovered: result.metadata?.toolsDiscovered || 0,
duration: result.duration,
});
// === MCP ENHANCEMENT: Lazy-init ToolRouter when 2+ servers exist ===
if (this.mcpEnhancementsConfig?.router?.enabled !== false) {
const servers = this.externalServerManager.listServers();
if (servers.length >= 2 && !this.mcpToolRouter) {
this.mcpToolRouter = new ToolRouter({
strategy: this.mcpEnhancementsConfig?.router?.strategy ?? "least-loaded",
enableAffinity: this.mcpEnhancementsConfig?.router?.enableAffinity ?? false,
});
// Register all existing servers
for (const server of servers) {
this.mcpToolRouter.registerServer(server.id || serverId);
}
logger.debug("[NeuroLink] ToolRouter auto-initialized (2+ external servers)");
}
else if (this.mcpToolRouter) {
this.mcpToolRouter.registerServer(serverId);
}
}
// Emit server added event
this.emitter.emit("externalMCP:serverAdded", {
serverId,
serverName: config.name || serverId,
config,
toolCount: result.metadata?.toolsDiscovered || 0,
timestamp: Date.now(),
});
}
else {
// Single ERROR record for a failed registration — the inner layers
// (client factory, server manager) log at debug so one root cause no
// longer fans out into 5 ERROR lines. Carry enough context here to
// diagnose without the inner lines.
mcpLogger.error(`[NeuroLink] Failed to add external MCP server: ${serverId}`, {
error: result.error,
transport: config.transport,
command: config.command,
url: config.url ? redactUrlCredentials(config.url) : undefined,
});
}
return result;
}
catch (error) {
mcpLogger.error(`[NeuroLink] Error adding external MCP server: ${serverId}`, error);
throw error;
}
}
/**
* Remove an external MCP server
* Stops the server and removes all its tools
* @param serverId - ID of the server to remove
* @returns Operation result
*/
async removeExternalMCPServer(serverId) {
this.invalidateToolCache(); // Invalidate cache when an external server is removed
try {
mcpLogger.info(`[NeuroLink] Removing external MCP server: ${serverId}`);
// Capture the configured name before removal destroys the instance
const serverName = this.externalServerManager.getServerName(serverId);
const result = await this.externalServerManager.removeServer(serverId);
if (result.success) {
mcpLogger.info(`[NeuroLink] External MCP server removed successfully: ${serverId}`);
// Emit server removed event
this.emitter.emit("externalMCP:serverRemoved", {
serverId,
serverName,
timestamp: Date.now(),
});
}
else if (result.error?.includes("not found")) {
// Expected no-op: consumers commonly call remove as cleanup after a
// failed add, when the server never registered. Not an error.
mcpLogger.debug(`[NeuroLink] Remove skipped — external MCP server not registered: ${serverId}`);
}
else {
mcpLogger.error(`[NeuroLink] Failed to remove external MCP server: ${serverId}`, {
error: result.error,
});
}
return result;
}
catch (error) {
mcpLogger.error(`[NeuroLink] Error removing external MCP server: ${serverId}`, error);
throw error;
}
}
/**
* List all external MCP servers
* @returns Array of server health information
*/
listExternalMCPServers() {
const serverStatuses = this.externalServerManager.getServerStatuses();
const allServers = this.externalServerManager.listServers();
return serverStatuses.map((health) => {
const server = allServers.find((s) => s.id === health.serverId);
return {
serverId: health.serverId,
status: health.status,
toolCount: health.toolCount,
uptime: health.performance.uptime,
isHealthy: health.isHealthy,
config: server || {},
};
});
}
/**
* Get external MCP server status
* @param serverId - ID of the server
* @returns Server instance or undefined if not found
*/
getExternalMCPServer(serverId) {
return this.externalServerManager.getServer(serverId);
}
/**
* Execute a tool from an external MCP server
* @param serverId - ID of the server
* @param toolName - Name of the tool
* @param parameters - Tool parameters
* @param options - Execution options
* @returns Tool execution result
*/
async executeExternalMCPTool(serverId, requestedToolName, parameters, options) {
// Falls back to the requested name so the catch block below still has
// something meaningful to log if resolution itself is what throws.
let toolName = requestedToolName;
try {
// Direct-boundary name repair (see resolveDirectMcpToolName): an exact
// match returns requestedToolName unchanged, so every existing caller
// — including the AI-SDK generation path's createExternalMCPTool,
// which always calls with an already-discovered name — is unaffected.
toolName = this.resolveDirectMcpToolName(serverId, requestedToolName);
mcpLogger.debug(`[NeuroLink] Executing external MCP tool: ${toolName} on ${serverId}`);
// BZ-664: Check existing ToolResultCache before executing to avoid
// duplicate identical calls within the same session.
//
// Safety guards aligned with executeToolInternal():
// - Skip destructive tools (destructiveHint annotation)
// - Scope cache key by serverId (two servers can expose same tool name)
// and toolExecutionContext (prevents cross-session/user leaks)
const toolAnnotations = this.getToolAnnotationsForExecution(toolName);
const cacheEnabled = !!this.mcpToolResultCache &&
!this._disableToolCacheForCurrentRequest &&
!toolAnnotations?.destructiveHint;
const cacheKeyArgs = {
__serverId: serverId,
__args: parameters,
...(this.toolExecutionContext
? { __ctx: this.toolExecutionContext }
: {}),
};
// Same repeat-within-request bypass as executeToolInternal: a model
// re-calling the identical tool+args in one turn wants fresh state.
const externalRepeatKey = this._generationTurnActive
? this.toolCacheRepeatKey(toolName, cacheKeyArgs)
: undefined;
const isExternalRepeatThisRequest = externalRepeatKey !== undefined &&
this._toolCacheKeysServedThisRequest.has(externalRepeatKey);
if (externalRepeatKey !== undefined) {
this._toolCacheKeysServedThisRequest.add(externalRepeatKey);
}
if (cacheEnabled &&
this.mcpToolResultCache &&
!isExternalRepeatThisRequest) {
const cached = this.mcpToolResultCache.getCachedResult(toolName, cacheKeyArgs);
if (cached !== undefined) {
mcpLogger.debug(`[NeuroLink] Tool result cache HIT: ${toolName} on ${serverId}`);
return cached;
}
}
const result = await this.externalServerManager.executeTool(serverId, toolName, parameters, options);
// BZ-664: Store result in cache after successful execution.
// Only cache SUCCESSFUL results. Caching an error result (isError:true /
// success:false — e.g. an upstream 403/timeout surfaced as a tool error)
// replays that identical failure to every caller with the same args for
// the whole TTL, turning a single transient upstream error into a storm
// of cached failures and preventing a retry from re-hitting the server
// once it recovers. Errors must always re-execute. (Detection mirrors the
// isToolError check used elsewhere in this file.)
const resultObj = result && typeof result === "object"
? result
: undefined;
const isErrorResult = Boolean((resultObj && "isError" in resultObj && resultObj.isError === true) ||
(resultObj && "success" in resultObj && resultObj.success === false));
// Also skip an `undefined` result: the cache read side treats `undefined`
// as a miss (`cached !== undefined`), so storing it is a dead entry that can
// never be read back — keep write/read symmetric with the other cache sites.
if (cacheEnabled &&
this.mcpToolResultCache &&
!isErrorResult &&
result !== undefined) {
this.mcpToolResultCache.cacheResult(toolName, cacheKeyArgs, result);
}
mcpLogger.debug(`[NeuroLink] External MCP tool ${isErrorResult ? "returned error" : "executed successfully"}: ${toolName}`);
return result;
}
catch (error) {
mcpLogger.error(`[NeuroLink] External MCP tool execution failed: ${toolName}`, error);
throw error;
}
}
/**
* Resolve a tool name for a direct external MCP execution
* (`executeExternalMCPTool`) against the server's currently discovered
* tools.
*
* `experimental_repairToolCall` only runs inside the AI-SDK's own
* streamText/generateText loop (see toolCallRepair.ts), so a near-miss
* tool name reaching `executeExternalMCPTool` directly previously had no
* recovery at all — just the generic `Tool 'x' not found for server 'y'`
* `Error` that `ToolDiscoveryService.executeTool` throws deeper in the
* stack. This reuses the same name-matching policy
* (`resolveToolName`: case-insensitive exact → unambiguous substring →
* Levenshtein) so a repair here is accepted under exactly the rules
* already proven for the generation path.
*
* Exact match is a zero-risk fast path: it is returned unchanged before
* any resolution attempt, so every existing caller that already sends a
* valid name — including the AI-SDK path's `createExternalMCPTool`, which
* always executes with a name it just discovered — sees no behaviour
* change.
*
* Deliberately does NOT attempt a repair when the server is unknown or
* not connected: `ExternalServerManager.executeTool` throws distinct,
* more accurate errors for those states ("Server 'x' not found" /
* "not in connected state"), and resolving against an empty tool list
* here would replace those with a misleading "tool not found" instead.
*/
resolveDirectMcpToolName(serverId, requestedName) {
const server = this.getExternalMCPServer(serverId);
if (!server || server.status !== "connected" || !server.client) {
return requestedName;
}
const availableNames = this.getExternalMCPServerTools(serverId).map((tool) => tool.name);
if (availableNames.includes(requestedName)) {
return requestedName;
}
const resolution = resolveToolName(requestedName, availableNames);
if (!resolution) {
throw new ExternalMcpToolNotFoundError(requestedName, serverId, rankToolNameCandidates(requestedName, availableNames));
}
// Recorded on a short-lived span rather than the method's return value:
// executeExternalMCPTool returns the raw upstream tool result verbatim
// (widely consumed as-is, e.g. by createExternalMCPTool's execute()),
// so wrapping it to carry resolution metadata would be a breaking
// change for every existing direct caller.
tracers.mcp.startActiveSpan("neurolink.mcp.toolNameRepair", {
attributes: {
"mcp.server_id": serverId,
"mcp.tool_name.requested": requestedName,
"mcp.tool_name.resolved": resolution.name,
"mcp.tool_name.repair_strategy": resolution.strategy,
...(resolution.score !== undefined
? { "mcp.tool_name.repair_score": resolution.score }
: {}),
},
}, (span) => span.end());
mcpLogger.info(`[NeuroLink] Repaired external MCP tool name at direct execution boundary: "${requestedName}" → "${resolution.name}" (${resolution.strategy}) on server '${serverId}'`);
return resolution.name;
}
/**
* Get all tools from external MCP servers
* @returns Array of external tool information
*/
getExternalMCPTools() {
return this.externalServerManager.getAllTools();
}
/**
* Get tools from a specific external MCP server
* @param serverId - ID of the server
* @returns Array of tool information for the server
*/
getExternalMCPServerTools(serverId) {
return this.externalServerManager.getServerTools(serverId);
}
/**
* Test connection to an external MCP server
* @param config - Server configuration to test
* @returns Test result with connection status
*/
async testExternalMCPConnection(config) {
try {
const { MCPClientFactory } = await withTimeout(import("./mcp/mcpClientFactory.js"), 10000);
const testResult = await MCPClientFactory.testConnection(config, 10000);
return {
success: testResult.success,
error: testResult.error,
toolCount: testResult.capabilities ? 1 : 0, // Basic indication
};
}
catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Get external MCP server manager statistics
* @returns Statistics about external servers and tools
*/
getExternalMCPStatistics() {
return this.externalServerManager.getStatistics();
}
/**
* Shutdown all external MCP servers
* Called automatically on process exit
*/
async shutdownExternalMCPServers() {
try {
mcpLogger.info("[NeuroLink] Shutting down all external MCP servers...");
// First, unregister all external MCP tools from the main tool registry
this.unregisterAllExternalMCPToolsFromRegistry();
// Then shutdown the external server manager
await this.externalServerManager.shutdown();
mcpLogger.info("[NeuroLink] All external MCP servers shut down successfully");
}
catch (error) {
mcpLogger.error("[NeuroLink] Error shutting down external MCP servers:", error);
throw error;
}
}
// ===== MCP ENHANCEMENTS SDK METHODS =====
/**
* Get the global elicitation manager for interactive tool input
* Elicitation allows tools to request additional information from users during execution
* @returns The global ElicitationManager instance
* @example
* ```typescript
* const elicitationManager = neurolink.getElicitationManager();
*
* // Register a handler for confirmations
* elicitationManager.registerHandler(async (request) => {
* if (request.type === 'confirmation') {
* const answer = await askUser(request.message);
* return { confirmed: answer === 'yes' };
* }
* });
* ```
*/
async getElicitationManager() {
// Dynamically import to avoid circular dependencies
const mod = (await withTimeout(import("./mcp/elicitation/index.js"), 10000));
return mod.globalElicitationManager;
}
/**
* Register an elicitation handler for interactive tool input
* Handlers are called when tools need user input during execution
* @param handler - Function to handle elicitation requests
* @example
* ```typescript
* neurolink.registerElicitationHandler(async (request) => {
* switch (request.type) {
* case 'confirmation':
* return { confirmed: await confirmWithUser(request.message) };
* case 'text':
* return { value: await promptUser(request.message) };
* case 'select':
* return { value: await selectFromOptions(request.options) };
* }
* });
* ```
*/
async registerElicitationHandler(handler) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const elicitationManager = (await this.getElicitationManager());
elicitationManager.registerHandler(handler);
}
/**
* Get the multi-server manager for load balancing and coordination
* Allows managing multiple MCP servers with failover and load balancing
* @returns The global MultiServerManager instance
* @example
* ```typescript
* const multiServer = neurolink.getMultiServerManager();
*
* // Create a server group with load balancing
* await multiServer.createServerGroup('ai-tools', {
* servers: ['openai-server', 'anthropic-server'],
* strategy: 'round-robin'
* });
* ```
*/
async getMultiServerManager() {
const mod = (await withTimeout(import("./mcp/multiServerManager.js"), 10000));
return mod.globalMultiServerManager;
}
/**
* Get the enhanced tool discovery service
* Provides advanced search, filtering, and compatibility checking for tools
* @returns EnhancedToolDiscovery instance
* @example
* ```typescript
* const discovery = neurolink.getEnhancedToolDiscovery();
*
* // Search for tools by criteria
* const results = await discovery.searchTools({
* category: 'data-processing',
* capabilities: ['streaming', 'batch'],
* minReliability: 0.9
* });
* ```
*/
async getEnhancedToolDiscovery() {
const mod = (await withTimeout(import("./mcp/enhancedToolDiscovery.js"), 10000));
return new mod.EnhancedToolDiscovery(this.toolRegistry);
}
/**
* Get the MCP registry client for discovering servers from registries
* Supports multiple registry sources (official, community, custom)
* @returns The global MCPRegistryClient instance
* @example
* ```typescript
* const registryClient = neurolink.getMCPRegistryClient();
*
* // Search for servers
* const servers = await registryClient.searchServers({
* query: 'database',
* categories: ['data', 'storage']
* });
*
* // Get a well-known server config
* const githubServer = registryClient.getWellKnownServer('github');
* ```
*/
async getMCPRegistryClient() {
const mod = (await withTimeout(import("./mcp/mcpRegistryClient.js"), 10000));
return mod.globalMCPRegistryClient;
}
/**
* Expose a NeuroLink agent as an MCP tool
* This allows agents to be called by other systems via MCP
* @param agent - The agent to expose (must include id, name, description, and execute)
* @param options - Exposure configuration options (prefix, defaultAnnotations, etc.)
* @returns The exposed tool definition
* @example
* ```typescript
* const agent = {
* id: 'my-agent',
* name: 'My Agent',
* description: 'An agent that processes data',
* execute: async (params) => { ... }
* };
* const tool = await neurolink.exposeAgentAsTool(agent, {
* prefix: 'agent_'
* });
* ```
*/
async exposeAgentAsTool(agent, options) {
const agentExposure = await withTimeout(import("./mcp/agentExposure.js"), 10000);
return agentExposure.exposeAgentAsTool(agent, options);
}
/**
* Expose a workflow as an MCP tool
* This allows workflows to be called by other systems via MCP
* @param workflow - The workflow to expose (must include id, name, description, and execute)
* @param options - Exposure configuration options (prefix, defaultAnnotations, etc.)
* @returns The exposed tool definition
* @example
* ```typescript
* const workflow = {
* id: 'data-pipeline',
* name: 'Data Pipeline',
* description: 'Runs the data processing pipeline',
* execute: async (params) => { ... }
* };
* const tool = await neurolink.exposeWorkflowAsTool(workflow, {
* prefix: 'workflow_'
* });
* ```
*/
async exposeWorkflowAsTool(workflow, options) {
const agentExposure = await withTimeout(import("./mcp/agentExposure.js"), 10000);
return agentExposure.exposeWorkflowAsTool(workflow, options);
}
/**
* Get the tool integration manager for middleware and elicitation
* Provides advanced tool wrapping with confirmation, timeout, retry, etc.
* @returns The global ToolIntegrationManager instance
* @example
* ```typescript
* const integration = neurolink.getToolIntegrationManager();
*
* // Register a tool with middleware
* integration.registerTool(myTool, {
* timeout: 30000,
* retries: 3,
* requireConfirmation: true
* });
* ```
*/
async getToolIntegrationManager() {
const mod = (await withTimeout(import("./mcp/toolIntegration.js"), 10000));
return mod.globalToolIntegrationManager;
}
/**
* Convert NeuroLink tools to MCP format
* Useful for exposing local tools to external MCP clients
* @param tools - Array of NeuroLink tool definitions
* @param options - Conversion options
* @returns Array of MCP-formatted tools
* @example
* ```typescript
* const mcpTools = neurolink.convertToolsToMCPFormat([
* { name: 'myTool', description: 'Does something', execute: async () => {} }
* ]);
* ```
*/
async convertToolsToMCPFormat(tools, options = {}) {
const mod = (await withTimeout(import("./mcp/toolConverter.js"), 10000));
// Ensure all tools have an execute function (required by NeuroLinkTool)
const normalizedTools = tools.map((tool) => ({
...tool,
execute: tool.execute ??
(async () => ({
success: false,
error: "No execute function provided",
})),
}));
return mod.batchConvertToMCP(normalizedTools, options);
}
/**
* Convert MCP tools to NeuroLink format
* Useful for importing tools from external MCP servers
* @param tools - Array of MCP tool definitions
* @param options - Conversion options
* @returns Array of NeuroLink-formatted tools
* @example
* ```typescript
* const neurolinkTools = neurolink.convertToolsFromMCPFormat(externalTools, {
* removeNamespacePrefix: 'external_'
* });
* ```
*/
async convertToolsFromMCPFormat(tools, options = {}) {
const mod = (await withTimeout(import("./mcp/toolConverter.js"), 10000));
return mod.batchConvertToNeuroLink(tools, options);
}
/**
* Get tool annotations and safety information
* Provides insights about tool behavior, safety levels, and retry-ability
* @param toolName - Name of the tool to analyze
* @returns Tool annotation summary
* @example
* ```typescript
* const annotations = await neurolink.getToolAnnotations('deleteFile');
* // Returns: { destructive: true, requiresConfirmation: true, safeToRetry: false }
* ```
*/
async getToolAnnotations(toolName) {
const { inferAnnotations, mergeAnnotations, getAnnotationSummary } = await withTimeout(import("./mcp/toolAnnotations.js"), 10000);
const toolInfo = this.toolRegistry.getToolInfo(toolName);
if (!toolInfo) {
return null;
}
// Check for explicit annotations set on the tool first
const explicitAnnotations = toolInfo.tool
.annotations;
// Infer annotations from the tool name/description as fallback
const inferredAnnotations = inferAnnotations({
name: toolInfo.tool.name,
description: toolInfo.tool.description ?? "",
});
// Merge: inferred first, then explicit overrides (explicit takes precedence)
const annotations = mergeAnnotations(inferredAnnotations, explicitAnnotations);
return {
annotations,
summary: getAnnotationSummary(annotations),
};
}
/**
* Convert external MCP tools to Vercel AI SDK tool format
* This allows AI providers to use external tools directly
*/
convertExternalMCPToolsToAISDKFormat() {
const externalTools = this.externalServerManager.getAllTools();
const aiSDKTools = {};
for (const tool of externalTools) {
if (tool.isAvailable) {
// Create tool definition without parameters schema to avoid Zod issues
// The AI provider will handle parameters dynamically based on the tool description
const toolDefinition = {
description: tool.description,
execute: async (params) => {
try {
mcpLogger.debug(`[NeuroLink] Executing external MCP tool via AI SDK: ${tool.name}`, { params });
const result = await this.externalServerManager.executeTool(tool.serverId, tool.name, params, { timeout: 30000 });
mcpLogger.debug(`[NeuroLink] External MCP tool execution result: ${tool.name}`, {
success: !!result,
hasData: !!(result &&
typeof result === "object" &&
"content" in result),
});
return result;
}
catch (error) {
mcpLogger.error(`[NeuroLink] External MCP tool execution failed: ${tool.name}`, error);
throw error;
}
},
};
// Only add parameters if we have a valid schema - otherwise omit it entirely
// This prevents Zod schema parsing errors
aiSDKTools[tool.name] = toolDefinition;
mcpLogger.debug(`[NeuroLink] Converted external MCP tool to AI SDK format: ${tool.name} from server ${tool.serverId}`);
}
}
mcpLogger.info(`[NeuroLink] Converted ${Object.keys(aiSDKTools).length} external MCP tools to AI SDK format`);
return aiSDKTools;
}
/**
* Convert JSON Schema to AI SDK compatible format
* For now, we'll skip schema validation and let the AI SDK handle parameters dynamically
*/
convertJSONSchemaToAISDKFormat(_inputSchema) {
// The simplest approach: don't provide parameters schema
// This lets the AI SDK handle the tool without schema validation
// Tools will still work, they just won't have strict parameter validation
return undefined;
}
/**
* Unregister external MCP tools from a specific server
*/
unregisterExternalMCPToolsFromRegistry(serverId) {
try {
const externalTools = this.externalServerManager.getServerTools(serverId);
for (const tool of externalTools) {
this.toolRegistry.removeTool(tool.name);
mcpLogger.debug(`[NeuroLink] Unregistered external MCP tool from main registry: ${tool.name}`);
}
}
catch (error) {
mcpLogger.error(`[NeuroLink] Failed to unregister external MCP tools from registry for server ${serverId}:`, error);
}
}
/**
* Unregister a specific external MCP tool from the main registry
*/
unregisterExternalMCPToolFromRegistry(toolName) {
try {
this.toolRegistry.removeTool(toolName);
mcpLogger.debug(`[NeuroLink] Unregistered external MCP tool from main registry: ${toolName}`);
}
catch (error) {
mcpLogger.error(`[NeuroLink] Failed to unregister external MCP tool ${toolName} from registry:`, error);
}
}
/**
* Lazily initialize conversation memory when needed
* This is called the first time a generate or stream operation is performed
*/
async lazyInitializeConversationMemory(generateInternalId, generateInternalStartTime, generateInternalHrTimeStart) {
try {
// Import the integration module
const { initializeConversationMemory } = await import("./core/conversationMemoryInitializer.js");
// Use the integration module to create the appropriate memory manager
const memoryManager = await initializeConversationMemory(this.conversationMemoryConfig);
// Assign to conversationMemory with proper type to handle both memory manager types
this.conversationMemory = memoryManager;
// Reset the lazy init flag since we've now initialized
this.conversationMemoryNeedsInit = false;
}
catch (error) {
logger.error(`[NeuroLink] ❌ LOG_POINT_G005_MEMORY_LAZY_INIT_ERROR`, {
logPoint: "G005_MEMORY_LAZY_INIT_ERROR",
generateInternalId,
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - generateInternalStartTime,
elapsedNs: (process.hrtime.bigint() - generateInternalHrTimeStart).toString(),
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : "UnknownError",
errorStack: error instanceof Error ? error.stack : undefined,
message: "Lazy conversation memory initialization failed",
});
throw error;
}
}
/**
* Unregister all external MCP tools from the main registry
*/
unregisterAllExternalMCPToolsFromRegistry() {
try {
const externalTools = this.externalServerManager.getAllTools();
for (const tool of externalTools) {
this.toolRegistry.removeTool(tool.name);
}
mcpLogger.debug(`[NeuroLink] Unregistered ${externalTools.length} external MCP tools from main registry`);
}
catch (error) {
mcpLogger.error("[NeuroLink] Failed to unregister all external MCP tools from registry:", error);
}
}
// ========================================
// Evaluation & Scoring API
// ========================================
/**
* Create an evaluation pipeline with the specified configuration or preset.
* Pipelines orchestrate multiple scorers to evaluate AI responses comprehensively.
*
* @param configOrPreset - Pipeline configuration object or preset name
* @returns Initialized evaluation pipeline
*
* @example Using a preset
* ```typescript
* const neurolink = new NeuroLink();
* const pipeline = await neurolink.createEvaluationPipeline('rag');
* const result = await pipeline.execute({
* query: 'What is the capital of France?',
* response: 'Paris is the capital of France.',
* context: ['France is a country in Europe. Paris is its capital.']
* });
* console.log(result.overallScore, result.passed);
* ```
*
* @example Using custom configuration
* ```typescript
* const pipeline = await neurolink.createEvaluationPipeline({
* name: 'custom-quality',
* scorers: [
* { id: 'toxicity', config: { threshold: 0.9 } },
* { id: 'hallucination', config: { weight: 1.5 } },
* { id: 'answer-relevancy' }
* ],
* aggregation: { method: 'weighted' },
* passThreshold: 0.8
* });
* ```
*/
async createEvaluationPipeline(configOrPreset) {
const { EvaluationPipeline, getPreset } = await withTimeout(import("./evaluation/pipeline/index.js"), 10000, ErrorFactory.evaluationTimeout("evaluation module load", 10000));
let config;
if (typeof configOrPreset === "string") {
// It's a preset name
config = getPreset(configOrPreset);
}
else {
// It's a custom configuration
config = configOrPreset;
}
const pipeline = new EvaluationPipeline(config);
// Note: withTimeout races the promise but does not abort in-flight LLM calls.
// Full AbortController propagation into pipeline/scorer internals is planned.
await withTimeout(pipeline.initialize(), 30000, ErrorFactory.evaluationTimeout("pipeline initialization", 30000));
logger.debug(`[NeuroLink] Created evaluation pipeline: ${config.name ?? "custom"}`);
return pipeline;
}
/**
* Run the `decide` inference type: evaluate a `state` against a batch of
* named, typed questions and get one typed answer each.
*
* This is NOT {@link NeuroLink.evaluate}, which scores an already-generated
* response with RAGAS scorers. `decide` calls a decision model — one that
* emits no text at all and returns calibrated, typed judgements your code
* can branch on.
*
* **Batch, never fan out.** On a decision model, latency is flat in
* question count (measured: 1 question 393ms, 400 questions 465ms) while
* concurrent requests queue. Put every question you might need into one
* call — speculative questions are nearly free, a second round trip is not.
*
* A `choice` answer carries the full probability distribution, so one
* question over N options also ranks all N.
*
* @example
* ```typescript
* const result = await neurolink.decide({
* state: ticketText,
* questions: {
* team: {
* type: "choice",
* instructions: "Which team should handle this?",
* criteria: { billing: "Payments", technical: "Bugs", sales: "Pricing" },
* },
* urgent: { type: "boolean", instructions: "Is this urgent?" },
* },
* });
* const team = readDecisionChoice(result.answers, "team");
* if (team && team.confidence > 0.7) { assign(team.choice); }
* ```
*
* @throws when no decision provider is configured, or the call fails
*/
async decide(options) {
// SDK config counts as much as the environment: credentials given to the
// constructor or to this call can configure a provider on their own.
const providerName = options.provider ??
resolveDefaultDecisionProvider(this.resolveCredentials(options.credentials));
if (!providerName) {
throw new Error(`No decision provider is configured. Set ${describeDecisionProviderKeys()} (in the environment or in \`credentials\`), or pass \`provider\` explicitly.`);
}
const questionCount = Object.keys(options.questions).length;
// Observability parity with generate()/stream(): an OTEL span for the
// trace view, a serialized span for the in-process metrics aggregator,
// and a cost figure. Without these a decision model is invisible — and
// "invisible" is the failure mode that matters here, because every
// internal consumer is fail-open, so a decision path that has silently
// stopped working looks exactly like one that was never configured.
return withSpan({
name: `gen_ai.${providerName}.decide`,
tracer: tracers.decision,
attributes: {
"gen_ai.system": providerName,
"gen_ai.operation.name": "decide",
"ai.provider": providerName,
"decision.question_count": questionCount,
"decision.images.count": options.images?.length ?? 0,
},
}, async (otelSpan) => {
const { traceId, parentSpanId } = getActiveTraceContext();
const startedAt = Date.now();
let span = SpanSerializer.createSpan(SpanType.MODEL_DECISION, `decision.${providerName}`, {
"ai.provider": providerName,
"ai.model": options.model ?? "",
"decision.question_count": questionCount,
"decision.images.count": options.images?.length ?? 0,
}, parentSpanId, traceId);
try {
// enableMCP: false — a decision model has no tools and never will.
const provider = await AIProviderFactory.createProvider(providerName, options.model, false, this, undefined, this.resolveCredentials(options.credentials));
if (typeof provider.decide !== "function") {
throw new Error(`Provider "${providerName}" does not serve the decide inference type.`);
}
const result = await provider.decide({
state: options.state,
questions: options.questions,
images: options.images,
video: options.video,
model: options.model,
signal: options.signal,
timeoutMs: options.timeoutMs,
});
const answerCount = Object.keys(result.answers).length;
const cost = calculateCost(providerName, result.model, {
input: result.usage.inputTokens,
output: result.usage.outputTokens,
total: result.usage.inputTokens + result.usage.outputTokens,
});
otelSpan.setAttribute("gen_ai.response.model", result.model);
otelSpan.setAttribute("gen_ai.usage.input_tokens", result.usage.inputTokens);
otelSpan.setAttribute("gen_ai.usage.output_tokens", result.usage.outputTokens);
otelSpan.setAttribute("decision.answer_count", answerCount);
otelSpan.setAttribute("decision.media.bytes", result.mediaBytes ?? 0);
otelSpan.setAttribute("ai.cost.total", cost);
if (result.upstreamMs !== undefined) {
otelSpan.setAttribute("decision.upstream_ms", result.upstreamMs);
}
if (result.requestId) {
otelSpan.setAttribute("gen_ai.response.id", result.requestId);
}
span.durationMs = Date.now() - startedAt;
span = SpanSerializer.updateAttributes(span, {
// The key names the cost aggregator reads. `ai.model` is
// overwritten with the RESOLVED model (e.g. "jev-1.13.0"), not
// the alias that was sent, so spend is attributed to what
// actually ran.
"ai.model": result.model,
"ai.tokens.input": result.usage.inputTokens,
"ai.tokens.output": result.usage.outputTokens,
"ai.cost.input": cost,
"ai.cost.output": 0,
"ai.cost.total": cost,
"decision.answer_count": answerCount,
"decision.media.bytes": result.mediaBytes ?? 0,
"decision.latency_ms": result.latencyMs,
...(result.upstreamMs !== undefined
? { "decision.upstream_ms": result.upstreamMs }
: {}),
});
// BOTH aggregators, exactly as the generate path does. The
// instance one is what `neurolink.getMetrics()` / `getSpans()`
// read; the global one is what the exporters drain. Recording to
// only the global one would make a decision invisible to the very
// API a caller would use to check whether it ran.
const ended = SpanSerializer.endSpan(span, SpanStatus.OK);
this.metricsAggregator.recordSpan(ended);
getMetricsAggregator().recordSpan(ended);
return result;
}
catch (error) {
span.durationMs = Date.now() - startedAt;
const ended = SpanSerializer.endSpan(span, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
this.metricsAggregator.recordSpan(ended);
getMetricsAggregator().recordSpan(ended);
throw error;
}
});
}
/**
* Fail-open {@link NeuroLink.decide}: returns null on any failure instead
* of throwing.
*
* This is the contract every internal consumer relies on. A decision model
* that is unconfigured, slow, rate-limited or down must never change
* NeuroLink's observable behaviour — the caller falls back to whatever it
* did before the decision was available.
*
* It also takes more questions than a provider's per-request cap: the map is
* split at the cap, the batches run a few at a time, and the answers come
* back joined. A batch that fails costs only its own answers, which every
* consumer already reads as "no decision"; null comes back only when every
* batch failed. `decide()` itself stays strict and refuses an over-cap request.
*/
async tryDecide(options) {
try {
const result = await this.decideInBatches(options);
if (logger.shouldLog("debug")) {
logger.debug(`decide: ${Object.keys(result.answers).length} answers in ${result.latencyMs}ms` +
(result.upstreamMs ? ` (upstream ${result.upstreamMs}ms)` : ""), {
provider: result.provider,
model: result.model,
requestId: result.requestId,
usage: result.usage,
});
}
return result;
}
catch (error) {
logger.debug(`decide unavailable: ${error instanceof Error ? error.message : String(error)} — falling back`);
return null;
}
}
/**
* One `decide()` call, or, when the question map is longer than the resolved
* provider's cap, several run a few at a time with their results joined. The
* provider is resolved here exactly as `decide()` resolves it, so the cap that
* is read is the cap of the provider that will answer.
*/
async decideInBatches(options) {
const providerName = options.provider ??
resolveDefaultDecisionProvider(this.resolveCredentials(options.credentials));
// The name a caller passes may be an alias (`jev` for `typesafe`), so it is
// resolved to the canonical provider before its descriptor is read.
const canonical = providerName
? PROVIDER_ALIAS_INDEX.get(providerName.toLowerCase())
: undefined;
const cap = canonical
? PROVIDER_DESCRIPTORS_BY_NAME.get(canonical)?.decisionLimits
?.maxQuestions
: undefined;
const batches = splitDecisionRequest(options, cap);
if (batches.length === 1) {
return this.decide(options);
}
const answered = [];
let firstFailure;
for (let from = 0; from < batches.length; from += MAX_PARALLEL_DECISION_BATCHES) {
const settled = await Promise.allSettled(batches
.slice(from, from + MAX_PARALLEL_DECISION_BATCHES)
.map((batch) => this.decide(batch)));
for (const outcome of settled) {
if (outcome.status === "fulfilled") {
answered.push(outcome.value);
}
else {
firstFailure ??= outcome.reason;
}
}
}
const [first, ...rest] = answered;
if (first === undefined) {
throw firstFailure instanceof Error
? firstFailure
: new Error(String(firstFailure));
}
if (rest.length + 1 < batches.length) {
logger.debug(`decide: ${batches.length - answered.length} of ${batches.length} batches failed; using the answers of the rest`);
}
return mergeDecisionResults([first, ...rest]);
}
/**
* Evaluate an AI response using the specified pipeline or scorers.
* This is a convenience method that creates a pipeline and executes it in one call.
*
* @param input - Scorer input containing query, response, and optional context
* @param options - Evaluation options including pipeline preset or custom scorers
* @returns Evaluation pipeline result with scores and pass/fail status
*
* @example Using a preset
* ```typescript
* const neurolink = new NeuroLink();
* const result = await neurolink.evaluate(
* {
* query: 'Explain quantum computing',
* response: 'Quantum computing uses qubits...'
* },
* { pipeline: 'quality' }
* );
* console.log(`Score: ${result.overallScore}, Passed: ${result.passed}`);
* ```
*
* @example Using specific scorers
* ```typescript
* const result = await neurolink.evaluate(
* {
* query: 'What causes rain?',
* response: 'Rain is caused by water vapor...',
* context: ['The water cycle involves evaporation...']
* },
* { scorers: ['hallucination', 'faithfulness', 'answer-relevancy'] }
* );
* ```
*
* @example Full RAG evaluation
* ```typescript
* const result = await neurolink.evaluate(
* {
* query: 'Who wrote Hamlet?',
* response: 'Shakespeare wrote Hamlet in 1600.',
* context: ['William Shakespeare wrote Hamlet around 1600-1601.'],
* groundTruth: 'William Shakespeare'
* },
* { pipeline: 'rag' }
* );
* ```
*/
async evaluate(input, options) {
const { EvaluationPipeline, getPreset } = await withTimeout(import("./evaluation/pipeline/index.js"), 10000, ErrorFactory.evaluationTimeout("evaluation module load", 10000));
let config;
// Fail fast on conflicting or empty evaluator selection
if (options?.pipeline && options?.scorers) {
throw new Error("Cannot specify both 'pipeline' and 'scorers' options. Use one or the other.");
}
if (options?.scorers && options.scorers.length === 0) {
throw new Error("The 'scorers' array must not be empty. Provide at least one scorer ID or omit the option to use the default 'quality' preset.");
}
if (options?.pipeline) {
// Use preset
config = { ...getPreset(options.pipeline) };
}
else if (options?.scorers && options.scorers.length > 0) {
// Use custom scorers
config = {
name: "SDK Evaluation",
description: "Evaluation from NeuroLink SDK",
scorers: options.scorers.map((id) => ({ id })),
executionMode: options.executionMode ?? "parallel",
passThreshold: options.passThreshold ?? 0.7,
};
}
else {
// Default to quality preset
config = getPreset("quality");
}
// Apply overrides
if (options?.passThreshold !== undefined) {
config.passThreshold = options.passThreshold;
}
if (options?.executionMode !== undefined) {
config.executionMode = options.executionMode;
}
const pipeline = new EvaluationPipeline(config);
await withTimeout(pipeline.initialize(), 30000, ErrorFactory.evaluationTimeout("pipeline initialization", 30000));
const executionTimeoutMs = options?.timeoutMs ?? 60000;
const result = await withTimeout(pipeline.execute(input, {
correlationId: options?.correlationId,
}), executionTimeoutMs, ErrorFactory.evaluationTimeout("pipeline execution", executionTimeoutMs));
logger.debug(`[NeuroLink] Evaluation completed`, {
pipeline: config.name,
overallScore: result.overallScore,
passed: result.passed,
scorerCount: result.scores.length,
});
return result;
}
/**
* Score a response using a single scorer.
* Useful for quick, targeted evaluations without the overhead of a full pipeline.
*
* @param scorerId - The ID of the scorer to use (e.g., 'toxicity', 'hallucination')
* @param input - Scorer input containing query, response, and optional context
* @param config - Optional scorer configuration overrides
* @returns Score result with value, reasoning, and pass/fail status
*
* @example Basic scoring
* ```typescript
* const neurolink = new NeuroLink();
* const result = await neurolink.score('toxicity', {
* query: '',
* response: 'This is a helpful response about cooking recipes.'
* });
* console.log(`Toxicity Score: ${result.score}/10, Passed: ${result.passed}`);
* ```
*
* @example Hallucination detection
* ```typescript
* const result = await neurolink.score('hallucination', {
* query: 'What year was the Eiffel Tower built?',
* response: 'The Eiffel Tower was built in 1889.',
* context: ['The Eiffel Tower was constructed from 1887-1889.']
* });
* console.log(`Score: ${result.score}, Reasoning: ${result.reasoning}`);
* ```
*
* @example With custom threshold
* ```typescript
* const result = await neurolink.score(
* 'faithfulness',
* {
* query: 'Summarize the article',
* response: 'The article discusses...',
* context: ['Article content here...']
* },
* { threshold: 0.85, weight: 1.5 }
* );
* ```
*/
async score(scorerId, input, config) {
const { ScorerRegistry } = await withTimeout(import("./evaluation/scorers/index.js"), 10000, ErrorFactory.evaluationTimeout("scorer module load", 10000));
// Ensure built-in scorers are registered
await withTimeout(ScorerRegistry.registerBuiltInScorers(), 30000, ErrorFactory.evaluationTimeout("scorer bootstrap", 30000));
// Get the scorer
const scorer = await withTimeout(ScorerRegistry.getScorer(scorerId, config), 30000, ErrorFactory.evaluationTimeout(`scorer load: ${scorerId}`, 30000));
if (!scorer) {
throw ErrorFactory.scorerNotFound(scorerId);
}
// Validate input
const validation = scorer.validateInput(input);
if (!validation.valid) {
throw ErrorFactory.evaluationValidationFailed(scorerId, validation.errors);
}
// Execute scoring
const result = await withTimeout(scorer.score(input), 60000, ErrorFactory.evaluationTimeout("scorer execution", 60000));
logger.debug(`[NeuroLink] Scoring completed`, {
scorerId,
score: result.score,
passed: result.passed,
computeTime: result.computeTime,
});
return result;
}
/**
* Get a list of all available scorers and their metadata.
* Useful for discovering what evaluation capabilities are available.
*
* @param options - Filter options
* @returns Array of scorer metadata
*
* @example List all scorers
* ```typescript
* const neurolink = new NeuroLink();
* const scorers = await neurolink.getAvailableScorers();
* for (const scorer of scorers) {
* console.log(`${scorer.id}: ${scorer.description} (${scorer.type})`);
* }
* ```
*
* @example Filter by category
* ```typescript
* const safetyScorers = await neurolink.getAvailableScorers({
* category: 'safety'
* });
* console.log('Safety scorers:', safetyScorers.map(s => s.id));
* ```
*
* @example Filter by type
* ```typescript
* const ruleBasedScorers = await neurolink.getAvailableScorers({
* type: 'rule'
* });
* ```
*/
async getAvailableScorers(options) {
const { ScorerRegistry } = await withTimeout(import("./evaluation/scorers/index.js"), 10000, ErrorFactory.evaluationTimeout("scorer module load", 10000));
// Ensure built-in scorers are registered
await withTimeout(ScorerRegistry.registerBuiltInScorers(), 30000, ErrorFactory.evaluationTimeout("scorer bootstrap", 30000));
let scorers = ScorerRegistry.list();
// Apply filters
if (options?.category) {
scorers = scorers.filter((s) => s.category === options.category);
}
if (options?.type) {
scorers = scorers.filter((s) => s.type === options.type);
}
return scorers;
}
/**
* Get a list of available evaluation pipeline presets.
* Presets are pre-configured pipelines for common evaluation scenarios.
*
* @returns Array of preset names
*
* @example
* ```typescript
* const neurolink = new NeuroLink();
* const presets = await neurolink.getEvaluationPresets();
* console.log('Available presets:', presets);
* // Output: ['safety', 'rag', 'quality', 'comprehensive', 'minimal', ...]
* ```
*/
async getEvaluationPresets() {
const { getPresetNames } = await withTimeout(import("./evaluation/pipeline/index.js"), 10000, ErrorFactory.evaluationTimeout("evaluation module load", 10000));
return getPresetNames();
}
/**
* Get details of a specific evaluation preset.
*
* @param presetName - Name of the preset
* @returns Pipeline configuration for the preset
*
* @example
* ```typescript
* const neurolink = new NeuroLink();
* const ragPreset = await neurolink.getEvaluationPreset('rag');
* console.log('RAG preset scorers:', ragPreset.scorers.map(s => s.id));
* console.log('Pass threshold:', ragPreset.passThreshold);
* ```
*/
async getEvaluationPreset(presetName) {
const { getPreset } = await withTimeout(import("./evaluation/pipeline/index.js"), 10000, ErrorFactory.evaluationTimeout("evaluation module load", 10000));
return getPreset(presetName);
}
// ============================================================================
// MULTI-AGENT ORCHESTRATION METHODS
// ============================================================================
/**
* Create an Agent instance for multi-agent orchestration.
*
* Agents are specialized AI entities with defined instructions, tools, and behavior.
* They can be composed into networks for complex task orchestration.
*
* @param definition - Agent definition specifying behavior and capabilities
* @returns A new Agent instance
*
* @example
* ```typescript
* const researcher = neurolink.createAgent({
* id: 'researcher',
* name: 'Research Agent',
* description: 'Searches and analyzes information from various sources',
* instructions: 'You are a research assistant. Search thoroughly and cite sources.',
* tools: ['websearchGrounding', 'readFile'],
* model: 'gpt-4o'
* });
*
* const result = await researcher.execute('Find recent AI breakthroughs');
* ```
*
* @see {@link AgentDefinition} for definition options
* @see {@link Agent} for agent methods
* @since 8.38.0
*/
async createAgent(definition) {
const { Agent } = await import("./agent/agent.js");
logger.debug("[NeuroLink] Creating agent", {
id: definition.id,
name: definition.name,
tools: definition.tools?.length || 0,
});
return new Agent(definition, this);
}
/**
* Create an AgentNetwork for multi-agent orchestration.
*
* Networks coordinate multiple agents, workflows, and tools with intelligent
* LLM-powered routing. The router agent analyzes tasks and delegates to
* the most appropriate primitive.
*
* @param config - Network configuration with agents, workflows, and routing settings
* @returns A new AgentNetwork instance
*
* @example
* ```typescript
* const network = neurolink.createNetwork({
* name: 'Content Team',
* description: 'Collaborative content creation pipeline',
* agents: [
* {
* id: 'researcher',
* name: 'Researcher',
* description: 'Finds and verifies information',
* instructions: 'Research topics thoroughly...',
* },
* {
* id: 'writer',
* name: 'Writer',
* description: 'Creates engaging content',
* instructions: 'Write clear, engaging content...',
* },
* {
* id: 'editor',
* name: 'Editor',
* description: 'Reviews and improves content',
* instructions: 'Review for clarity and accuracy...',
* }
* ],
* router: {
* model: 'gpt-4o',
* confidenceThreshold: 0.7
* }
* });
*
* const result = await network.execute({
* message: 'Write an article about quantum computing'
* });
* ```
*
* @see {@link AgentNetworkConfig} for configuration options
* @see {@link AgentNetwork} for network methods
* @since 8.38.0
*/
async createNetwork(config) {
const { AgentNetwork } = await import("./agent/agentNetwork.js");
logger.debug("[NeuroLink] Creating agent network", {
name: config.name,
agentCount: config.agents.length,
workflowCount: config.workflows?.length || 0,
toolCount: config.tools?.length || 0,
});
return new AgentNetwork(config, this);
}
/**
* Create a worker-mode NeuroLink instance for sub-agent execution.
*
* Worker mode is the framework-provided version of the config block every
* consumer used to copy by hand: conversation memory OFF, orchestration
* OFF, observability inherited from this instance with
* `autoDetectExternalProvider: true` + `skipLangfuseSpanProcessor: true`
* (worker spans join the host's tracer without duplicate Langfuse
* exports), credentials inherited, the host's tool registry shared (so
* worker tool calls reuse the host's connections), and an internal log
* bridge attached with a caller-supplied tag.
*
* Dispose the worker (`worker.dispose()`) when done — `runIsolatedAgent`
* does this automatically in a `finally`.
*
* @param options - Worker options (log tag/sink, registry sharing, config)
* @returns A new worker-mode NeuroLink instance
* @see {@link WorkerInstanceOptions}
*/
createWorkerInstance(options) {
const tag = options?.logTag ?? "worker";
const hostEmitter = this.emitter;
const configOverrides = (options?.config ?? {});
const workerConfig = {
...(this.credentials ? { credentials: this.credentials } : {}),
...configOverrides,
// Worker-mode fields always win over the config merge.
conversationMemory: { enabled: false },
enableOrchestration: false,
observability: {
...(this.observabilityConfig ?? {}),
langfuse: {
...(this.observabilityConfig?.langfuse ?? {}),
autoDetectExternalProvider: true,
skipLangfuseSpanProcessor: true,
},
},
...(options?.shareToolRegistry !== false && {
toolRegistry: this.toolRegistry,
}),
};
const workerTools = this.workerToolsConfig(configOverrides.tools);
const worker = new NeuroLink(workerTools ? { ...workerConfig, tools: workerTools } : workerConfig);
// The cacheable:false flag lives BESIDE the registry, not in it, so a
// shared registry alone would re-enable caching on the worker for exactly
// the tools whose results are live state (tasks_list, command_status,
// collect_results). The worker inherits the host's full uncacheable set.
if (options?.shareToolRegistry !== false) {
for (const uncacheableName of this.uncacheableTools) {
worker.uncacheableTools.add(uncacheableName);
}
}
// Constructing an instance rebinds the process-global logger sink to the
// new instance's emitter — restore the host as the active sink so host
// log bridges keep flowing while workers come and go.
logger.setEventEmitter(hostEmitter);
if (options?.onLog) {
const onLog = options.onLog;
const forward = (raw) => {
try {
const entry = (raw ?? {});
onLog({
tag,
level: String(entry.level ?? "info"),
message: String(entry.message ?? ""),
timestamp: typeof entry.timestamp === "number"
? entry.timestamp
: Date.now(),
data: entry.data,
});
}
catch {
// Log-bridge listener errors never disrupt the worker.
}
};
// Bridge the WORKER's own scoped sink, not the host's process-wide one.
// Subscribing to hostEmitter would deliver every log event in the
// process — host, sibling workers, MCP — stamped with this worker's
// tag. The scoped registration delivers only events logged inside the
// worker's own generate/stream/generateText calls.
const bridgeEmitter = {
emit: (event, ...args) => {
if (event === "log-event") {
forward(args[0]);
}
return true;
},
};
const workerLogInstanceId = worker.logInstanceId;
logger.addScopedEventEmitter(workerLogInstanceId, bridgeEmitter);
// Tracked on the HOST so that disposing the host — not just the
// worker — is enough to remove this bridge if the caller never
// disposes the worker itself. See `ownedWorkerLogBridgeDetachers`.
const detachBridge = () => {
logger.removeScopedEventEmitter(workerLogInstanceId, bridgeEmitter);
this.ownedWorkerLogBridgeDetachers.delete(detachBridge);
};
this.ownedWorkerLogBridgeDetachers.add(detachBridge);
const originalDispose = worker.dispose.bind(worker);
worker.dispose = async () => {
detachBridge();
await originalDispose();
};
}
logger.debug("[NeuroLink] Created worker instance", {
tag,
sharedToolRegistry: options?.shareToolRegistry !== false,
});
return worker;
}
/**
* Run an isolated sub-agent: a worker instance (see
* {@link createWorkerInstance}) executes a tool-using research pass under
* the turn budget (wrap-up nudge, stall watchdog, honest `stopReason`),
* then an extraction pass ALWAYS runs tools-off on its own timeout with a
* structured-recovery ladder and corrective re-asks. A non-empty execution
* record never produces an empty result (mechanical digest fallback), a
* parent `abortSignal` stops everything cleanly, and `options.leg` enables
* leashed mode with TTL'd resume handles ({@link continueAgent} /
* {@link stopAgent}).
*
* @param definition - Agent definition (+ optional structured extraction)
* @param input - Task input: string or structured object
* @param options - Run options (abort, overrides, tool context, events, leg)
* @returns The run outcome
* @see {@link IsolatedAgentDefinition}
* @see {@link AgentRunOptions}
* @see {@link AgentRunOutcome}
*/
async runIsolatedAgent(definition, input, options) {
const { runIsolatedAgent } = await import("./agent/isolatedAgentRunner.js");
return runIsolatedAgent(this, definition, input, options);
}
/**
* Resume a leashed isolated-agent run by handle. `guidance`, when given,
* is appended as a user turn before the next leg — the supervisor's
* re-steering channel. An expired handle returns its tombstoned final
* outcome exactly once.
*
* @param handle - Handle from an `in_progress` {@link AgentRunOutcome}
* @param guidance - Optional supervisor guidance for the next leg
* @returns The next leg's outcome (or the final outcome)
*/
async continueAgent(handle, guidance) {
const { continueIsolatedAgent } = await import("./agent/isolatedAgentRunner.js");
return continueIsolatedAgent(this, handle, guidance);
}
/**
* Stop a leashed isolated-agent run: dispose its worker and return the
* final outcome (mechanical digest over everything gathered so far).
*
* @param handle - Handle from an `in_progress` {@link AgentRunOutcome}
* @returns The final outcome
*/
async stopAgent(handle) {
const { stopIsolatedAgent } = await import("./agent/isolatedAgentRunner.js");
return stopIsolatedAgent(this, handle);
}
/**
* Register an isolated agent as a delegation tool on THIS instance, so
* its existing generate() loop can delegate — no second router generate.
* Framework policy (per-turn caps, depth withholding, a process-wide
* concurrency pool with queue timeout) is enforced in the loop itself,
* and every refusal carries its recovery instruction in the error text.
*
* @param definition - Agent definition (+ optional structured extraction)
* @param options - Registration options (name, caps, depth, pool, leg)
* @returns The registered tool name
* @see {@link AgentToolRegistrationOptions}
*/
async registerAgentTool(definition, options) {
const { registerAgentTool } = await import("./agent/agentToolRegistrar.js");
const registered = registerAgentTool(this, definition, options);
this.hasAgentTools = true;
return registered;
}
/**
* Register the task CHECKLIST toolset — `tasks_create`, `tasks_update`,
* `tasks_list` — on this instance (TodoWrite-style planning for a
* long-running run). Opt-in and idempotent: existing callers see no new
* tools until they ask for them.
*
* The checklist is session state, not conversation state: it lives outside
* the message list, so summarization/compaction cannot lose it, and every
* tool result returns the whole list so the model re-anchors for free after
* a compaction. Read the same state from host code with
* {@link getTaskState} — that is all a completeness gate needs.
*
* Sessions come from the tool execution context. Call
* `setToolContext({ sessionId })` (or run the agent through
* `runIsolatedAgent`, which stamps one) so the checklist has a stable
* identity; a model tool call with no session anywhere falls back to a
* single default checklist per instance rather than one per call. A DIRECT
* `executeTool("tasks_create", …)` call should pass
* `authContext: { sessionId }` — the tool registry otherwise mints a fresh
* id for that one call.
*
* @see {@link getTaskState} for the host-side read
*/
registerTaskTools() {
if (this.hasTaskChecklistTools) {
return;
}
const tools = createChecklistTools(this);
for (const [toolName, toolDef] of Object.entries(tools)) {
// Never cacheable: every one of these reads or mutates live checklist
// state, so a cached `tasks_list` would report a list that has already
// moved on — the exact silent staleness the checklist exists to prevent.
this.registerTool(toolName, toolDef, { cacheable: false });
}
this.hasTaskChecklistTools = true;
logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} task checklist tools`);
}
/**
* Read a session's task checklist — synchronous, so a completeness gate is
* one line of host code:
* `getTaskState(id).items.filter(i => i.status === "pending")`.
*
* Never throws: an unknown session simply has an empty checklist. Omit
* `sessionId` to read the session the tools would currently write to (the
* instance's tool-context session, or its default checklist).
*/
getTaskState(sessionId) {
return getChecklistState(sessionId ?? resolveChecklistSessionId(this));
}
/**
* Drop a session's checklist. Returns whether there was one to drop.
* Omit `sessionId` to clear the session the tools currently write to.
*/
clearTaskState(sessionId) {
return clearChecklistState(sessionId ?? resolveChecklistSessionId(this));
}
// ========================================
// Async Delegation API (N2)
// ========================================
/**
* Register the background-delegation toolset — `delegate_task` and
* `collect_results` — on this instance. Opt-in and idempotent: existing
* callers see no new tools until they ask for them.
*
* Delegation through {@link registerAgentTool} is synchronous — the loop
* blocks on each worker. These tools make it asynchronous: `delegate_task`
* returns a `workerId` at once and the agent keeps working, then
* `collect_results` claims whichever worker finished FIRST. Concurrency is
* bounded by the same process-wide pool `registerAgentTool` uses (raised,
* never lowered, by `maxConcurrent`), each worker's FULL report is banked to
* a file via {@link bankArtifact}, and the outstanding counts ride along in
* every `tasks_list` result so the agent learns a worker landed without
* polling.
*
* @param options - Depth ceiling, pool raise, and queue wait
* @see {@link spawnDelegate} for the host-side spawn
* @see {@link collectDelegates} for the host-side collect
*/
registerDelegationTools(options) {
configureDelegation(this, options);
if (this.hasBackgroundDelegationTools) {
return;
}
const tools = createDelegationTools(this);
for (const [toolName, toolDef] of Object.entries(tools)) {
// Never cacheable: `delegate_task` starts a new worker every call and
// `collect_results` claims each outcome exactly once. A cached result
// would spawn nothing and hand the same worker back twice.
this.registerTool(toolName, toolDef, { cacheable: false });
}
this.hasBackgroundDelegationTools = true;
logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} background delegation tools`);
}
/**
* Start a background worker and get its handle immediately — before it has
* run anything, and long before it finishes.
*
* The worker runs through {@link runIsolatedAgent}: a fresh session on a
* worker instance sharing THIS instance's tool registry (so live MCP
* connections are reused), waste detection, honest stop reasons. Its
* complete report is banked when it settles; the outcome you collect carries
* a bounded summary plus the read-back call for the rest.
*
* @example
* ```typescript
* const a = await neurolink.spawnDelegate({ task: "Audit the auth changes" });
* const b = await neurolink.spawnDelegate({ task: "Review the migrations" });
* // …keep working…
* const first = await neurolink.collectDelegates({ mode: "any" });
* ```
*
* @param options - Task, scope, context, tool allowlist, budgets
* @returns The worker id, spawn time, and whether it is queued for a slot
* @throws when the task is empty or the caller is at the depth ceiling
*/
async spawnDelegate(options) {
return spawnBackgroundDelegate(this, options);
}
/**
* Claim finished background workers — in COMPLETION order, which has nothing
* to do with spawn order. Each outcome is handed out exactly once.
*
* @param request - `{ mode: "any" | "all" }` or `{ workerId }`, plus `waitMs`
* @returns Claimed outcomes plus what is still pending/ready
*/
async collectDelegates(request) {
return collectBackgroundDelegates(this, request);
}
/**
* Cancel background workers: one by id, or every outstanding worker this
* instance spawned. Cancelled workers still settle into a claimable outcome
* saying so.
*
* @param workerId - Cancel just this worker; omit to cancel all
* @returns How many workers were cancelled
*/
async cancelDelegates(workerId) {
return cancelBackgroundDelegates(this, workerId);
}
// ========================================
// Artifact Banking API (N3)
// ========================================
/**
* This instance's artifact store, created on first use.
*
* Until now a store existed only when `mcp.outputLimits.strategy` was set to
* `"externalize"`, so a caller that just wanted to bank a worker report had
* to configure MCP output limits it did not use. This creates one on demand
* and registers `retrieve_context` alongside it, so a banked payload is
* readable by the model, not only by host code.
*
* Already-configured instances get the store they already had — the MCP
* output normalizer and banking deliberately share one store, so an
* externalized tool output and a banked report read back the same way.
*
* @returns The artifact store backing {@link bankArtifact} / {@link readArtifact}
*/
getArtifactStore() {
if (!this.mcpArtifactStore) {
this.mcpArtifactStore = this.createConfiguredArtifactStore();
}
// A no-op when the constructor already registered it.
this.registerMemoryRetrievalTools();
return this.mcpArtifactStore;
}
/**
* Replace this instance's artifact store.
*
* Everything that writes or reads artifacts follows the swap: banking,
* `retrieve_context`, host-side `readArtifact`, and the MCP output
* normalizer — which is rebuilt here because it captured the previous store
* at construction. Assigning the field alone would miss it, and
* externalized tool outputs would keep landing in the old backend while
* read-backs looked in the new one.
*
* Call it before the first bank or externalized tool output: artifacts
* already in the previous store are not migrated, and their ids stop
* resolving through this instance. `artifacts.store` in the constructor
* config is the same thing without the ordering concern.
*
* Ownership: a store you hand in — here or via `artifacts.store` — stays
* yours to close. NeuroLink closes only the stores it built itself, when
* they are replaced here and on `shutdown()`.
*
* @param store - Any {@link ArtifactStore}
*/
setArtifactStore(store) {
const previous = this.mcpArtifactStore;
if (previous === store) {
return;
}
if (previous) {
logger.warn("[NeuroLink] Artifact store replaced — artifacts already written to " +
"the previous store will not resolve through this instance");
if (this.ownsArtifactStore && previous.close) {
void previous.close().catch((error) => {
logger.warn("[NeuroLink] Previous artifact store close failed", {
error: error instanceof Error ? error.message : String(error),
});
});
}
}
this.mcpArtifactStore = store;
this.ownsArtifactStore = false;
this.installOutputNormalizer(store);
this.registerMemoryRetrievalTools();
}
/**
* Build the store the `artifacts` config (or `STORAGE_TYPE`) asks for. The
* Redis connection falls back to conversation memory's, so one
* `STORAGE_TYPE=redis` moves sessions and artifacts together.
*/
createConfiguredArtifactStore() {
const injected = this.artifactsConfig?.store !== undefined;
const store = createArtifactStore(this.artifactsConfig, this.conversationMemoryConfig?.conversationMemory?.redisConfig);
// An injected store is returned as-is by the factory and stays the
// caller's; anything else was built here and is this instance's to close.
this.ownsArtifactStore = !injected;
logger.debug("[NeuroLink] Artifact store initialized", {
backend: injected
? "custom"
: resolveArtifactStorageType(this.artifactsConfig),
});
return store;
}
/**
* (Re)build the MCP output normalizer over `artifactStore`. A no-op unless
* `mcp.outputLimits` was configured — without it nothing is externalized.
*/
installOutputNormalizer(artifactStore) {
if (!this.mcpOutputNormalizerConfig) {
return;
}
this.externalServerManager.setOutputNormalizer(new McpOutputNormalizer(this.mcpOutputNormalizerConfig, artifactStore));
logger.debug("[NeuroLink] MCP output normalizer initialized", {
...this.mcpOutputNormalizerConfig,
hasArtifactStore: artifactStore !== undefined,
});
}
/**
* Bank a payload to a file and get back a pointer to it.
*
* The payload is stored WHOLE. What you put in the conversation is the
* returned `preview` (a bounded head slice) and `readBackHint` (the literal
* `retrieve_context` call that fetches the rest) — so a 4 MB worker report
* costs a few hundred tokens of context and loses nothing, and compaction
* can drop the preview without destroying evidence.
*
* @example
* ```typescript
* const ref = await neurolink.bankArtifact(fullReport, {
* kind: "worker-report",
* label: "delegate:auth-review",
* sessionId: "review-1421",
* });
* // Hand the model ref.preview + ref.readBackHint, never fullReport.
* ```
*
* @param payload Complete text or JSON. Never truncated.
* @param options `kind` and `label` are required; see {@link BankArtifactOptions}
* @returns Id, bounded preview, byte size, and the read-back call
*/
async bankArtifact(payload, options) {
return bankArtifactPayload(this, payload, options);
}
/**
* Read a banked payload back from host code — the programmatic twin of the
* model's `retrieve_context({ artifactId })` call.
*
* Omit `page` for the complete payload; pass `{ offset, limit }` to walk a
* large one in windows. Returns null when the id is unknown or expired.
*
* @param id `artifactId` from a {@link BankedArtifactRef}
* @param page Optional character window
*/
async readArtifact(id, page) {
return readBankedArtifact(this, id, page);
}
// ========================================
// Background Command API (N4)
// ========================================
/**
* Register the background-command toolset — `run_command_bg`,
* `command_status`, `command_output`, `command_kill` — on this instance, and
* declare what may be executed. Opt-in and idempotent: existing callers see
* no new tools until they ask for them.
*
* A reviewing agent needs to run real commands — a build, a test suite, a
* linter whose output is the evidence for a finding — without blocking its
* own loop and without losing a byte of what they printed. These tools start
* a command detached, write both streams to files as they arrive, and bank
* the COMPLETE files as artifacts when the command settles; the conversation
* gets a bounded tail plus the read-back call.
*
* The policy is not optional. `allowedExecutables` is matched exactly
* against `argv[0]`, `cwdRoot` is a realpath-checked sandbox, there is never
* a shell, and a command that outlives `defaultTimeoutMs` is killed.
*
* @param policy - What may run, where, for how long, and how loudly
* @see {@link startBackgroundCommand} for the host-side start
* @see {@link registerGitTools} for read-only git without a general policy
*/
registerBackgroundCommandTools(policy) {
setCommandPolicy(this, policy);
if (this.hasBackgroundCommandTools) {
return;
}
const tools = createBackgroundCommandTools(this);
for (const [toolName, toolDef] of Object.entries(tools)) {
// Never cacheable: every one of these reads or changes live process
// state. A cached `command_status` would report a build that has already
// finished as still running, for the whole TTL.
this.registerTool(toolName, toolDef, { cacheable: false });
}
this.hasBackgroundCommandTools = true;
logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} background command tools`);
}
/**
* Declare (or replace) what this instance may execute, without registering
* the model-facing tools. Host code that only drives
* {@link startBackgroundCommand} itself needs nothing more than this.
*
* @param policy - What may run, where, for how long, and how loudly
*/
setBackgroundCommandPolicy(policy) {
setCommandPolicy(this, policy);
}
/**
* Start a command in the background and get its task id immediately.
*
* @example
* ```typescript
* const { taskId } = await neurolink.startBackgroundCommand(
* ["pnpm", "run", "lint"],
* { cwd: repoRoot },
* );
* // …keep working…
* const status = await neurolink.awaitBackgroundCommand(taskId);
* const full = await neurolink.readArtifact(status.stdout!.artifactId);
* ```
*
* @param argv - Executable first, one entry per argument. Never a command string.
* @param options - cwd (sandboxed), timeout, byte cap, env, label, session
* @returns The task id, the argv that ran, and when it started
* @throws when no policy is set, argv is malformed, the executable is not
* allowlisted, the policy vetoes it, or the cwd escapes the sandbox
*/
async startBackgroundCommand(argv, options) {
return startCommand(this, argv, options);
}
/**
* Everything known about one command right now — synchronous, so a
* mid-loop monitor costs nothing.
*
* @param taskId - Task id from {@link startBackgroundCommand}
* @throws when the task id is unknown to this instance
*/
getBackgroundCommandStatus(taskId) {
return getCommandStatus(this, taskId);
}
/**
* Wait for a command to settle. `timeoutMs` bounds the WAIT, not the
* command: when it elapses the current status is returned rather than
* thrown, so a caller can poll in bounded steps and never lose the job.
*
* @param taskId - Task id from {@link startBackgroundCommand}
* @param opts - `timeoutMs` to bound the wait
*/
async awaitBackgroundCommand(taskId, opts) {
return awaitCommand(this, taskId, opts);
}
/**
* Kill a running command — SIGTERM, then SIGKILL five seconds later — and
* resolve with its settled status. Whatever it printed first is still
* banked: killing a command discards the process, never its output.
*
* @param taskId - Task id from {@link startBackgroundCommand}
* @param signal - Signal to send first. Default SIGTERM
*/
async killBackgroundCommand(taskId, signal) {
return killCommand(this, taskId, signal);
}
/**
* Read one character window of a command's output straight from its log
* file — while it is still running, or long after it finished. Offsets,
* `totalSize` and `hasMore` match `retrieve_context` exactly.
*
* @param taskId - Task id from {@link startBackgroundCommand}
* @param page - Which stream, and which window of it
*/
async readBackgroundCommandOutput(taskId, page) {
return readCommandOutput(this, taskId, page);
}
// ========================================
// Read-only Git Toolset (N4.4)
// ========================================
/**
* Register the read-only git toolset — `git_log`, `git_show`, `git_diff`,
* `git_blame`, `git_merge_base`, `git_ls_files` — on this instance. Opt-in
* and idempotent.
*
* These are BOUNDED tools, not a shell: the model supplies values (a ref, a
* path, a line range), never flags, and each tool assembles a fixed argv
* from them. That is what keeps them read-only — a free-form argument string
* would carry `--output=<file>` and `diff.external` straight through.
*
* Registering them widens nothing else: they run under a private
* one-executable policy rooted at `repoRoot`, so `run_command_bg` still
* cannot execute git, and no general command policy is required.
*
* @param options - Repository root, plus timeout / byte-cap / preview bounds
*/
registerGitTools(options) {
configureGitTools(this, options);
if (this.hasGitTools) {
return;
}
const tools = createGitTools(this);
for (const [toolName, toolDef] of Object.entries(tools)) {
// Never cacheable: the working tree moves under these answers.
this.registerTool(toolName, toolDef, { cacheable: false });
}
this.hasGitTools = true;
logger.info(`[NeuroLink] Registered ${Object.keys(tools).length} read-only git tools`);
}
/**
* Run one read-only git command from host code, with the same bounding the
* tools get: the complete stdout is banked, the result carries a preview and
* the read-back call.
*
* @param args - Git arguments, e.g. `["log", "--oneline"]`. Assembled by the
* caller, which is responsible for every value in them
* @param sessionId - Session the command belongs to
* @throws when {@link registerGitTools} has not been called
*/
async runGitCommand(args, sessionId) {
return runGitArgs(this, args, sessionId);
}
/**
* Execute an agent network with the given input.
*
* @param network - The agent network to execute
* @param input - Execution input (message and context)
* @param options - Optional execution options
* @returns Network execution result with content, trace, and usage
*
* @see {@link NetworkExecutionInput} for input options
* @see {@link NetworkExecutionResult} for result structure
* @since 8.38.0
*/
async executeNetwork(network, input, options) {
logger.debug("[NeuroLink] Executing agent network", {
networkId: network.id,
networkName: network.name,
hasContext: !!input.context,
});
return network.execute(input, options);
}
/**
* Stream agent network execution with real-time events.
*
* @param network - The agent network to stream
* @param input - Execution input (message and context)
* @param options - Optional execution options
* @returns Async iterable of network stream chunks
*
* @see {@link NetworkStreamChunk} for chunk types
* @since 8.38.0
*/
async *streamNetwork(network, input, options) {
logger.debug("[NeuroLink] Streaming agent network", {
networkId: network.id,
networkName: network.name,
hasContext: !!input.context,
});
yield* network.stream(input, options);
}
// ============================================================================
// ADVANCED ORCHESTRATION METHODS
// ============================================================================
/**
* Create a NetworkOrchestrator for managing multiple agent networks.
*
* @param config - Orchestrator configuration options
* @returns A new NetworkOrchestrator instance
* @since 8.38.0
*/
async createOrchestrator(config) {
const { NetworkOrchestrator } = await import("./agent/orchestration/index.js");
logger.debug("[NeuroLink] Creating network orchestrator", {
maxConcurrentExecutions: config?.maxConcurrentExecutions,
defaultMode: config?.defaultMode,
});
return new NetworkOrchestrator(this, config);
}
/**
* Create an AgentCoordinator for managing agent coordination strategies.
*
* @param config - Coordinator configuration options
* @returns A new AgentCoordinator instance
* @since 8.38.0
*/
async createCoordinator(config) {
const { AgentCoordinator } = await import("./agent/coordination/index.js");
logger.debug("[NeuroLink] Creating agent coordinator", {
strategy: config?.strategy,
maxConcurrency: config?.maxConcurrency,
});
return new AgentCoordinator(config);
}
/**
* Create a MessageBus for inter-agent communication.
*
* @param config - Message bus configuration options
* @returns A new MessageBus instance
* @since 8.38.0
*/
async createMessageBus(config) {
const { MessageBus } = await import("./agent/communication/index.js");
logger.debug("[NeuroLink] Creating message bus", {
maxHistorySize: config?.maxHistorySize,
});
return new MessageBus(config);
}
/**
* Dispose of all resources and cleanup connections
* Call this method when done using the NeuroLink instance to prevent resource leaks
* Especially important in test environments where multiple instances are created
*/
async dispose() {
logger.debug("[NeuroLink] Starting disposal of resources...");
// Clear per-session compaction watermarks
this.lastCompactionMessageCount.clear();
const cleanupErrors = [];
try {
// 0. Kill host-owned background work: a disposed instance must not
// leave child processes or delegate workers running.
try {
await killAllCommands(this);
await cancelBackgroundDelegates(this);
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`Background work cleanup error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error stopping background work:", error);
}
// 1. Flush and shutdown OpenTelemetry
try {
logger.debug("[NeuroLink] Flushing and shutting down OpenTelemetry...");
await flushOpenTelemetry();
await shutdownOpenTelemetry();
logger.debug("[NeuroLink] OpenTelemetry shutdown successfully");
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`OpenTelemetry shutdown error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error shutting down OpenTelemetry:", error);
}
// 2. Shutdown external MCP server connections
if (this.externalServerManager) {
try {
logger.debug("[NeuroLink] Shutting down external MCP servers...");
await this.externalServerManager.shutdown();
logger.debug("[NeuroLink] External MCP servers shutdown successfully");
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`External server shutdown error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error shutting down external MCP servers:", error);
}
}
// 3. Release the artifact store this instance built (a pooled Redis
// connection, for one). Same ownership rule as shutdown(): an injected
// store is the caller's to close. Without this step every
// construct / use / dispose cycle left the pooled reference acquired.
if (this.ownsArtifactStore && this.mcpArtifactStore?.close) {
try {
await this.mcpArtifactStore.close();
logger.debug("[NeuroLink] Artifact store closed");
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`Artifact store close error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error closing artifact store:", error);
}
}
// 4. Clear all event listeners to prevent memory leaks
if (this.emitter) {
try {
logger.debug("[NeuroLink] Removing all event listeners...");
this.emitter.removeAllListeners();
// Clear only if this instance's emitter is the active log sink —
// disposing a worker instance must not yank the host's bridge.
logger.clearEventEmitter(this.emitter);
// Scoped sinks live on the process-global logger, keyed by this
// instance's id — they outlive `this.emitter` and must be dropped
// explicitly or the sink (and its closure) leaks for the process.
logger.clearScopedEventEmitters(this.logInstanceId);
logger.debug("[NeuroLink] Event listeners removed successfully");
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`Event emitter cleanup error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error removing event listeners:", error);
}
}
// 4a. Detach any worker log bridges this instance created (via
// createWorkerInstance({ onLog })) that the caller never disposed
// individually. Their scoped sink lives on the process-global logger
// keyed by the WORKER's id, so it would otherwise outlive this host.
if (this.ownedWorkerLogBridgeDetachers.size > 0) {
try {
const count = this.ownedWorkerLogBridgeDetachers.size;
for (const detach of [...this.ownedWorkerLogBridgeDetachers]) {
detach();
}
logger.debug(`[NeuroLink] Detached ${count} undisposed worker log bridge(s)`);
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`Worker log bridge cleanup error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error detaching worker log bridges:", error);
}
}
// 5. Clear all circuit breakers
if (this.toolCircuitBreakers && this.toolCircuitBreakers.size > 0) {
try {
logger.debug(`[NeuroLink] Clearing ${this.toolCircuitBreakers.size} circuit breakers...`);
this.toolCircuitBreakers.clear();
logger.debug("[NeuroLink] Circuit breakers cleared successfully");
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`Circuit breaker cleanup error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error clearing circuit breakers:", error);
}
}
// 6. Clear all Maps and caches
try {
logger.debug("[NeuroLink] Clearing maps and caches...");
if (this.toolExecutionMetrics) {
this.toolExecutionMetrics.clear();
}
if (this.activeToolExecutions) {
this.activeToolExecutions.clear();
}
if (this.currentStreamToolExecutions) {
this.currentStreamToolExecutions.length = 0;
}
if (this.toolExecutionHistory) {
this.toolExecutionHistory.length = 0;
}
// Clear tool cache
if (this.toolCache) {
this.toolCache.tools = [];
this.toolCache.timestamp = 0;
}
// Cleanup MCP enhancement modules
this.mcpToolResultCache?.destroy();
this.mcpToolRouter?.destroy();
this.mcpToolBatcher?.destroy();
this.mcpToolResultCache = undefined;
this.mcpToolRouter = undefined;
this.mcpToolBatcher = undefined;
this.mcpEnhancedDiscovery = undefined;
this.mcpToolMiddlewares = [];
logger.debug("[NeuroLink] Maps and caches cleared successfully");
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`Cache cleanup error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error clearing caches:", error);
}
// 5b. Shutdown TaskManager
if (this._taskManager) {
try {
logger.debug("[NeuroLink] Shutting down TaskManager...");
await withTimeout(this._taskManager.shutdown(), 5000, new Error("TaskManager shutdown timed out"));
}
catch (error) {
logger.warn("[NeuroLink] TaskManager shutdown error:", error);
}
finally {
this._taskManager = undefined;
}
}
// 7. Reset initialization flags
try {
logger.debug("[NeuroLink] Resetting initialization state...");
this.mcpInitialized = false;
this.mcpInitPromise = null;
this.conversationMemoryNeedsInit = false;
this.credentials = undefined;
logger.debug("[NeuroLink] Initialization state reset successfully");
}
catch (error) {
const err = error instanceof Error
? error
: new Error(`State reset error: ${String(error)}`);
cleanupErrors.push(err);
logger.warn("[NeuroLink] Error resetting state:", error);
}
// 7. Log completion
if (cleanupErrors.length === 0) {
logger.debug("[NeuroLink] ✅ Resource disposal completed successfully");
}
else {
logger.warn(`[NeuroLink] ⚠️ Resource disposal completed with ${cleanupErrors.length} errors`, {
errors: cleanupErrors.map((e) => e.message),
});
}
}
catch (error) {
logger.error("[NeuroLink] Critical error during disposal:", error);
throw error;
}
}
// ============================================
// Internal Access Methods (for Server Adapters)
// ============================================
/**
* Get the tool registry instance
* Used internally by server adapters for tool management
* @returns The MCPToolRegistry instance
*/
getToolRegistry() {
return this.toolRegistry;
}
/**
* Manually trigger context compaction for a session.
* Runs the full 4-stage compaction pipeline.
*/
async compactSession(sessionId, config) {
if (!this.conversationMemory) {
return null;
}
const messages = await this.conversationMemory.buildContextMessages(sessionId);
if (!messages || messages.length === 0) {
return null;
}
const compactor = new ContextCompactor({
...config,
summarizationProvider: config?.summarizationProvider ??
this.conversationMemoryConfig?.conversationMemory
?.summarizationProvider,
summarizationModel: config?.summarizationModel ??
this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
}, this.contextCompactorDeps());
// Use actual context window to determine target, not arbitrary heuristic
const budgetInfo = checkContextBudget({
provider: config?.provider || "openai",
conversationMessages: messages,
});
// Target 60% of available input tokens — leave room for new messages
const targetTokens = Math.floor(budgetInfo.availableInputTokens * 0.6);
const result = await compactor.compact(messages, targetTokens, this.conversationMemoryConfig?.conversationMemory);
if (result.compacted) {
repairToolPairs(result.messages);
}
return result;
}
/**
* Get context usage statistics for a session.
* Returns token counts, usage ratio, and breakdown by category.
*/
async getContextStats(sessionId, provider, model) {
if (!this.conversationMemory) {
return null;
}
const messages = await this.conversationMemory.buildContextMessages(sessionId);
if (!messages || messages.length === 0) {
return null;
}
const budgetResult = checkContextBudget({
provider: provider || "openai",
model,
conversationMessages: messages,
});
return {
estimatedInputTokens: budgetResult.estimatedInputTokens,
availableInputTokens: budgetResult.availableInputTokens,
usageRatio: budgetResult.usageRatio,
shouldCompact: budgetResult.shouldCompact,
messageCount: messages.length,
};
}
/**
* Check if a session needs compaction.
*/
needsCompaction(sessionId, provider, model) {
if (!this.conversationMemory) {
return false;
}
const session = this.conversationMemory.getSession?.(sessionId);
if (!session) {
return false;
}
const budgetResult = checkContextBudget({
provider: provider || "openai",
model,
conversationMessages: session.messages,
});
return budgetResult.shouldCompact;
}
// ============================================================================
// Authentication Methods
// ============================================================================
/**
* Set the authentication provider for the NeuroLink instance
*
* @param config - Auth provider or configuration to create one
*/
async setAuthProvider(config) {
// Clear any pending lazy-init promise so it does not race with this call.
this.authInitPromise = undefined;
await this.initializeAuthProviderFromConfig(config);
}
async initializeAuthProviderFromConfig(config) {
let provider;
let providerType;
// Duck-type check: direct AuthProvider instance
if ("authenticateToken" in config &&
typeof config.authenticateToken === "function") {
provider = config;
providerType = provider.type;
}
else if ("provider" in config) {
provider = config.provider;
providerType = provider.type;
}
else {
const typedConfig = config;
const { AuthProviderFactory } = await import("./auth/AuthProviderFactory.js");
provider = await AuthProviderFactory.createProvider(typedConfig.type, typedConfig.config);
providerType = typedConfig.type;
}
this.authProvider = provider;
logger.info(`Auth provider set: ${providerType}`);
this.emitter.emit("auth:provider:set", {
type: provider.type,
timestamp: Date.now(),
});
}
/**
* Get the currently configured authentication provider
*/
getAuthProvider() {
return this.authProvider;
}
/**
* Lazily initialize the auth provider from pendingAuthConfig.
* Called on first use (generate/stream with auth token) to avoid
* async work in the synchronous constructor.
*/
async ensureAuthProvider() {
if (this.authProvider || !this.pendingAuthConfig) {
return;
}
const pendingAuthConfig = this.pendingAuthConfig;
this.authInitPromise ??= (async () => {
try {
await this.initializeAuthProviderFromConfig(pendingAuthConfig);
this.pendingAuthConfig = undefined;
}
finally {
if (this.authInitPromise &&
(this.pendingAuthConfig === undefined ||
this.pendingAuthConfig === pendingAuthConfig)) {
this.authInitPromise = undefined;
}
}
})();
await this.authInitPromise;
}
/**
* Set the current authentication context for request handling.
*
* Delegates to the global AuthContextHolder so that auth state is NOT
* stored as an instance field (which would leak between concurrent requests
* sharing the same NeuroLink singleton). Prefer `runWithAuthContext()` from
* `authContext.ts` for proper request-scoped context via AsyncLocalStorage.
*
* @param context - The authenticated user context
*/
async setAuthContext(context) {
const { globalAuthContext } = await import("./auth/authContext.js");
globalAuthContext.set(context);
logger.debug("Auth context set", {
userId: context.user.id,
provider: context.provider,
sessionId: context.session?.id,
});
}
/**
* Get the current authentication context.
*
* Checks AsyncLocalStorage first, then falls back to the global holder.
*/
async getAuthContext() {
const { getAuthContext: getCtx } = await import("./auth/authContext.js");
return getCtx();
}
/**
* Clear the current authentication context
*/
async clearAuthContext() {
const { globalAuthContext } = await import("./auth/authContext.js");
const userId = globalAuthContext.get()?.user.id;
globalAuthContext.clear();
if (userId) {
logger.debug(`Auth context cleared for user: ${userId}`);
}
}
/**
* Get the external server manager instance
* Used internally by server adapters for external MCP server management
* @returns The ExternalServerManager instance
*/
getExternalServerManager() {
return this.externalServerManager;
}
// ==========================================================================
// Dynamic Argument Resolution
// ==========================================================================
buildResolutionContext(signal, inlineContext) {
return {
requestContext: inlineContext || {},
signal,
};
}
/**
* Resolve dynamic arguments in GenerateOptions, mutating the options in place.
* Only resolves fields that are functions; static values pass through unchanged.
*/
async resolveDynamicOptions(options) {
const dynamicFields = [
"model",
"provider",
"temperature",
"maxTokens",
"systemPrompt",
"timeout",
"thinkingLevel",
"disableTools",
"enableAnalytics",
"enableEvaluation",
];
const hasDynamic = dynamicFields.some((f) => typeof options[f] === "function") ||
typeof options.tools === "function";
if (!hasDynamic) {
return;
}
const inlineCtx = options.dynamicContext;
await this.resolveDynamicFields(options, dynamicFields, inlineCtx);
}
async resolveDynamicFields(options, dynamicFields, inlineContext) {
const resolutionContext = this.buildResolutionContext(options.abortSignal, inlineContext);
logger.debug("[NeuroLink] Resolving dynamic arguments");
await Promise.all(dynamicFields.map(async (field) => {
if (typeof options[field] === "function") {
const result = await resolveDynamicArgument(options[field], resolutionContext);
options[field] = result.value;
logger.debug(`[NeuroLink] Resolved dynamic ${field}: ${result.resolutionType}`);
}
}));
// Handle dynamic tools → enabledToolNames mapping.
// Per DynamicOptions.tools: DynamicArgument<string[]>, the resolver
// must return an array of tool names. Anything else is a contract
// violation — fail fast rather than silently disabling tooling.
if (typeof options.tools === "function") {
const result = await resolveDynamicArgument(options.tools, resolutionContext);
if (!Array.isArray(result.value)) {
throw new TypeError(`Dynamic tools resolver must return string[] (tool names), got ${typeof result.value === "object" ? "object" : typeof result.value}`);
}
options.enabledToolNames = result.value;
delete options.tools;
}
}
}
// Create default instance
export const neurolink = new NeuroLink();
export default neurolink;