@juspay/neurolink 12.58.1 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +14 -3
  2. package/dist/auth/anthropicOAuth.js +3 -3
  3. package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
  4. package/dist/auth/sessionManager.js +4 -4
  5. package/dist/auth/tokenStore.js +4 -4
  6. package/dist/browser/neurolink.min.js +474 -473
  7. package/dist/cli/commands/observability.d.ts +1 -6
  8. package/dist/cli/commands/observability.js +0 -106
  9. package/dist/cli/commands/proxy.js +9 -18
  10. package/dist/cli/commands/telemetry.d.ts +3 -13
  11. package/dist/cli/commands/telemetry.js +7 -354
  12. package/dist/cli/loop/session.js +1 -1
  13. package/dist/client/httpClient.js +1 -1
  14. package/dist/client/sseClient.js +1 -1
  15. package/dist/client/streamingClient.js +2 -2
  16. package/dist/client/wsClient.js +1 -1
  17. package/dist/constants/langfuse.d.ts +25 -0
  18. package/dist/constants/langfuse.js +38 -0
  19. package/dist/constants/telemetry.d.ts +20 -0
  20. package/dist/constants/telemetry.js +34 -0
  21. package/dist/core/baseProvider.d.ts +2 -1
  22. package/dist/core/baseProvider.js +4 -11
  23. package/dist/core/conversationMemoryManager.js +5 -8
  24. package/dist/core/factory.js +1 -1
  25. package/dist/core/geminiLoopAdapter.d.ts +8 -1
  26. package/dist/core/geminiLoopAdapter.js +25 -1
  27. package/dist/core/loopEngine.js +69 -40
  28. package/dist/core/modules/MessageBuilder.js +2 -2
  29. package/dist/core/modules/StreamHandler.js +2 -2
  30. package/dist/core/modules/TelemetryHandler.d.ts +2 -18
  31. package/dist/core/modules/TelemetryHandler.js +1 -66
  32. package/dist/core/modules/ToolsManager.js +40 -31
  33. package/dist/core/nativeGenerateLoop.js +78 -8
  34. package/dist/core/redisConversationMemoryManager.js +8 -15
  35. package/dist/core/toolExecutionGuards.js +1 -13
  36. package/dist/index.d.ts +5 -12
  37. package/dist/index.js +5 -37
  38. package/dist/mcp/batching/requestBatcher.js +1 -1
  39. package/dist/mcp/externalServerManager.js +3 -21
  40. package/dist/mcp/toolDiscoveryService.js +4 -16
  41. package/dist/mcp/toolRegistry.js +2 -5
  42. package/dist/memory/memoryRetrievalTools.js +1 -1
  43. package/dist/neurolink.d.ts +16 -27
  44. package/dist/neurolink.js +788 -792
  45. package/dist/observability/exporterSpanProcessor.d.ts +50 -0
  46. package/dist/observability/exporterSpanProcessor.js +181 -0
  47. package/dist/observability/exporters/langfuseExporter.d.ts +13 -34
  48. package/dist/observability/exporters/langfuseExporter.js +101 -154
  49. package/dist/observability/index.d.ts +1 -0
  50. package/dist/observability/index.js +1 -0
  51. package/dist/observability/langfuseMasking.d.ts +8 -0
  52. package/dist/observability/langfuseMasking.js +47 -0
  53. package/dist/observability/otlpEncoding.d.ts +20 -0
  54. package/dist/observability/otlpEncoding.js +85 -0
  55. package/dist/providers/amazonBedrock/client.js +142 -176
  56. package/dist/providers/amazonBedrock/loopAdapter.js +14 -1
  57. package/dist/providers/amazonSagemaker.js +118 -80
  58. package/dist/providers/anthropic/client.js +3 -52
  59. package/dist/providers/anthropic/loopAdapter.js +24 -1
  60. package/dist/providers/anthropic/rateLimitCapture.js +9 -12
  61. package/dist/providers/googleAiStudio/client.d.ts +4 -5
  62. package/dist/providers/googleAiStudio/client.js +38 -40
  63. package/dist/providers/googleVertex/client.d.ts +2 -7
  64. package/dist/providers/googleVertex/client.js +282 -378
  65. package/dist/providers/googleVertex/constants.js +2 -2
  66. package/dist/providers/litellm/client.d.ts +2 -9
  67. package/dist/providers/litellm/client.js +1 -63
  68. package/dist/providers/openAI/client.d.ts +1 -8
  69. package/dist/providers/openAI/client.js +0 -65
  70. package/dist/providers/openaiChatCompletionsBase.d.ts +1 -0
  71. package/dist/providers/openaiChatCompletionsBase.js +61 -4
  72. package/dist/providers/replicate.js +22 -4
  73. package/dist/proxy/codexUsage.js +7 -0
  74. package/dist/proxy/proxyFetch.js +19 -25
  75. package/dist/proxy/proxyTracer.d.ts +14 -7
  76. package/dist/proxy/proxyTracer.js +129 -83
  77. package/dist/proxy/proxyTranslationEngine.js +0 -1
  78. package/dist/proxy/requestLogger.d.ts +1 -1
  79. package/dist/proxy/requestLogger.js +39 -45
  80. package/dist/server/middleware/common.js +1 -1
  81. package/dist/server/routes/agentRoutes.js +4 -4
  82. package/dist/server/routes/claudeProxyRoutes.js +99 -196
  83. package/dist/server/routes/codexProxyRoutes.js +621 -591
  84. package/dist/server/routes/healthRoutes.js +6 -6
  85. package/dist/server/routes/mcpRoutes.js +7 -7
  86. package/dist/server/routes/memoryRoutes.js +7 -7
  87. package/dist/server/routes/openApiRoutes.js +3 -3
  88. package/dist/server/routes/toolRoutes.js +5 -5
  89. package/dist/server/routes/transcribeRoutes.js +1 -1
  90. package/dist/server/utils/validation.d.ts +3 -3
  91. package/dist/services/server/ai/observability/instrumentation.d.ts +34 -201
  92. package/dist/services/server/ai/observability/instrumentation.js +220 -1267
  93. package/dist/services/server/ai/observability/langfuseSetup.d.ts +14 -0
  94. package/dist/services/server/ai/observability/langfuseSetup.js +55 -0
  95. package/dist/services/server/ai/observability/otlpSetup.d.ts +16 -0
  96. package/dist/services/server/ai/observability/otlpSetup.js +58 -0
  97. package/dist/session/globalSessionState.js +0 -11
  98. package/dist/telemetry/attributes.d.ts +23 -46
  99. package/dist/telemetry/attributes.js +76 -162
  100. package/dist/telemetry/chatMessages.d.ts +13 -0
  101. package/dist/telemetry/chatMessages.js +59 -0
  102. package/dist/telemetry/index.d.ts +5 -19
  103. package/dist/telemetry/index.js +5 -26
  104. package/dist/telemetry/observation.d.ts +37 -0
  105. package/dist/telemetry/observation.js +112 -0
  106. package/dist/telemetry/observationNames.d.ts +18 -0
  107. package/dist/telemetry/observationNames.js +24 -0
  108. package/dist/telemetry/spanSanitizer.d.ts +39 -0
  109. package/dist/telemetry/spanSanitizer.js +92 -0
  110. package/dist/telemetry/spanSerialization.d.ts +13 -0
  111. package/dist/telemetry/spanSerialization.js +46 -0
  112. package/dist/telemetry/tracers.d.ts +37 -18
  113. package/dist/telemetry/tracers.js +97 -19
  114. package/dist/telemetry/withSpan.d.ts +47 -25
  115. package/dist/telemetry/withSpan.js +158 -71
  116. package/dist/types/cli.d.ts +0 -11
  117. package/dist/types/decision.d.ts +3 -0
  118. package/dist/types/exporter.d.ts +54 -0
  119. package/dist/types/exporter.js +0 -4
  120. package/dist/types/generate.d.ts +4 -1
  121. package/dist/types/isolatedAgent.d.ts +5 -4
  122. package/dist/types/loopEngine.d.ts +18 -28
  123. package/dist/types/observability.d.ts +127 -160
  124. package/dist/types/providers.d.ts +15 -0
  125. package/dist/types/proxy.d.ts +41 -2
  126. package/dist/utils/anthropicTraceSanitizer.d.ts +2 -2
  127. package/dist/utils/anthropicTraceSanitizer.js +3 -1
  128. package/dist/utils/conversationMemory.js +4 -4
  129. package/dist/utils/fileDetector.js +1 -1
  130. package/dist/utils/messageBuilder.js +1 -1
  131. package/dist/utils/noOutputSentinel.d.ts +4 -16
  132. package/dist/utils/noOutputSentinel.js +12 -28
  133. package/dist/utils/providerRetry.js +4 -4
  134. package/dist/voice/livekit/brain.js +1 -1
  135. package/dist/voice/livekit/realtimeMemory.d.ts +1 -1
  136. package/dist/voice/livekit/realtimeMemory.js +66 -1
  137. package/dist/voice/livekit/realtimeVoiceAgent.js +1 -1
  138. package/docs-site/static/search-index.json +42 -63
  139. package/package.json +10 -4
  140. package/dist/telemetry/telemetryService.d.ts +0 -79
  141. package/dist/telemetry/telemetryService.js +0 -572
  142. package/dist/utils/observabilityHelpers.d.ts +0 -27
  143. package/dist/utils/observabilityHelpers.js +0 -47
package/dist/neurolink.js CHANGED
@@ -12,7 +12,9 @@ import { beginOllamaDiagnostic, captureOllamaDiagnosticContext, completeOllamaDi
12
12
  // each suppressed dotenv's banner — one conditional, one unconditional.
13
13
  import { loadProcessDotenv } from "./utils/dotenvBootstrap.js";
14
14
  await loadProcessDotenv();
15
- import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
15
+ import { SpanKind, SpanStatusCode, context, trace, } from "@opentelemetry/api";
16
+ import { getPropagatedAttributesFromContext } from "@langfuse/core";
17
+ import { LangfuseOtelSpanAttributes } from "@langfuse/tracing";
16
18
  import { AsyncLocalStorage } from "async_hooks";
17
19
  import { EventEmitter } from "events";
18
20
  import pLimit from "p-limit";
@@ -73,15 +75,18 @@ import { SpanSerializer } from "./observability/utils/spanSerializer.js";
73
75
  import { withSpan } from "./telemetry/withSpan.js";
74
76
  import { getActiveTraceContext } from "./telemetry/traceContext.js";
75
77
  import { calculateCost } from "./utils/pricing.js";
76
- import { flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, stampGuestRescueIdentity, } from "./services/server/ai/observability/instrumentation.js";
78
+ import { acquireTelemetry, ensureTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, isOpenTelemetryInitialized, releaseTelemetry, setLangfuseContext, traceMetadataFromContext, } from "./services/server/ai/observability/instrumentation.js";
77
79
  import { clearChecklistState, createChecklistTools, getChecklistState, resolveChecklistSessionId, } from "./agent/taskChecklist.js";
78
80
  import { cancelDelegates as cancelBackgroundDelegates, collectDelegates as collectBackgroundDelegates, configureDelegation, createDelegationTools, spawnDelegate as spawnBackgroundDelegate, } from "./agent/backgroundDelegation.js";
79
81
  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";
80
82
  import { configureGitTools, createGitTools, runGitCommand as runGitArgs, } from "./agent/gitTools.js";
81
83
  import { TaskManager } from "./tasks/taskManager.js";
82
84
  import { createTaskTools } from "./tasks/tools/taskTools.js";
83
- import { ATTR, spanJsonAttribute } from "./telemetry/attributes.js";
84
- import { tracers } from "./telemetry/tracers.js";
85
+ import { ATTR, spanJsonAttribute, spanModelRequestAttribute, } from "./telemetry/attributes.js";
86
+ import { assistantMessages } from "./telemetry/chatMessages.js";
87
+ import { observationLevel, observe, observeTrace, usageDetails, } from "./telemetry/observation.js";
88
+ import { toolSpanName } from "./telemetry/observationNames.js";
89
+ import { isNestedOperation, tracers } from "./telemetry/tracers.js";
85
90
  import { getConversationMessages, storeConversationTurn, } from "./utils/conversationMemory.js";
86
91
  // Enhanced error handling imports
87
92
  import { CircuitBreaker, ERROR_CODES, ErrorFactory, isAbortError, isRetriableError, logStructuredError, NeuroLinkError, withRetry, withTimeout, } from "./utils/errorHandling.js";
@@ -363,6 +368,147 @@ function createTypedEmitter() {
363
368
  const emitter = new EventEmitter();
364
369
  return emitter;
365
370
  }
371
+ function turnInput(optionsOrPrompt) {
372
+ if (typeof optionsOrPrompt === "string") {
373
+ return spanModelRequestAttribute({ prompt: optionsOrPrompt });
374
+ }
375
+ const options = optionsOrPrompt;
376
+ return spanModelRequestAttribute({
377
+ messages: options.conversationMessages,
378
+ prompt: options.input?.text,
379
+ });
380
+ }
381
+ function observeTurn(span, attrs) {
382
+ observe(span, "agent", attrs);
383
+ if (!isNestedOperation(span)) {
384
+ observeTrace(span, { input: attrs.input, output: attrs.output });
385
+ }
386
+ }
387
+ function startTurn(span, optionsOrPrompt) {
388
+ observeTurn(span, { input: turnInput(optionsOrPrompt) });
389
+ if (typeof optionsOrPrompt !== "string") {
390
+ const name = optionsOrPrompt.context?.observationName;
391
+ if (name) {
392
+ span.updateName(name);
393
+ }
394
+ }
395
+ if (!isNestedOperation(span)) {
396
+ observeTrace(span, {
397
+ metadata: traceMetadataFromContext(context.active()),
398
+ });
399
+ }
400
+ }
401
+ function failTurnSpan(span, error) {
402
+ const message = error.message;
403
+ if (isAbortError(error)) {
404
+ observeTurn(span, {
405
+ output: message,
406
+ level: "WARNING",
407
+ statusMessage: message,
408
+ });
409
+ return;
410
+ }
411
+ span.recordException(error);
412
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
413
+ observeTurn(span, {
414
+ output: message,
415
+ level: "ERROR",
416
+ statusMessage: message,
417
+ });
418
+ }
419
+ function traceTurnStream(result, span, turnContext) {
420
+ const iterator = result.stream[Symbol.asyncIterator]();
421
+ let output = "";
422
+ let ended = false;
423
+ let started = false;
424
+ const finish = () => {
425
+ ended = true;
426
+ span.end();
427
+ };
428
+ const end = (level, statusMessage) => {
429
+ observeTurn(span, {
430
+ output: output ? assistantMessages(output) : undefined,
431
+ metadata: { model: result.model },
432
+ level,
433
+ statusMessage,
434
+ });
435
+ finish();
436
+ };
437
+ const close = async () => {
438
+ if (ended) {
439
+ return;
440
+ }
441
+ try {
442
+ await context.with(turnContext, async () => {
443
+ await iterator.return?.();
444
+ });
445
+ }
446
+ finally {
447
+ end("WARNING", started
448
+ ? "stream closed by the consumer before it finished"
449
+ : "stream closed before first chunk");
450
+ }
451
+ };
452
+ const traced = {
453
+ async next() {
454
+ if (ended) {
455
+ return { done: true, value: undefined };
456
+ }
457
+ started = true;
458
+ try {
459
+ const step = await context.with(turnContext, () => iterator.next());
460
+ if (step.done) {
461
+ span.setStatus({ code: SpanStatusCode.OK });
462
+ end(observationLevel(result.finishReason));
463
+ return { done: true, value: undefined };
464
+ }
465
+ const content = typeof step.value === "object" && step.value !== null
466
+ ? step.value.content
467
+ : undefined;
468
+ if (typeof content === "string") {
469
+ output += content;
470
+ }
471
+ return step;
472
+ }
473
+ catch (error) {
474
+ failTurnSpan(span, error instanceof Error ? error : new Error(String(error)));
475
+ finish();
476
+ throw error;
477
+ }
478
+ },
479
+ async return() {
480
+ await close();
481
+ return { done: true, value: undefined };
482
+ },
483
+ async throw(error) {
484
+ await close();
485
+ throw error;
486
+ },
487
+ [Symbol.asyncIterator]() {
488
+ return traced;
489
+ },
490
+ };
491
+ return traced;
492
+ }
493
+ function drainInContext(stream, drainContext) {
494
+ return (async function* () {
495
+ const iterator = stream[Symbol.asyncIterator]();
496
+ try {
497
+ while (true) {
498
+ const step = await context.with(drainContext, () => iterator.next());
499
+ if (step.done) {
500
+ return;
501
+ }
502
+ yield step.value;
503
+ }
504
+ }
505
+ finally {
506
+ await context.with(drainContext, async () => {
507
+ await iterator.return?.();
508
+ });
509
+ }
510
+ })();
511
+ }
366
512
  /** Monotonic source for {@link NeuroLink.logInstanceId}. Process-local by design. */
367
513
  let neuroLinkInstanceCounter = 0;
368
514
  export class NeuroLink {
@@ -633,78 +779,28 @@ export class NeuroLink {
633
779
  // `undefined` = not yet attempted, `null` = init failed (stay disabled).
634
780
  skillsManagerInstance;
635
781
  skillsConfig;
636
- /**
637
- * Extract and set Langfuse context from options with proper async scoping
638
- */
639
782
  async setLangfuseContextFromOptions(options, callback) {
640
- if (options.context &&
641
- typeof options.context === "object" &&
642
- options.context !== null) {
643
- let callbackExecuted = false;
644
- try {
645
- const ctx = options.context;
646
- // Trigger context scoping if any meaningful Langfuse field is present
647
- if (ctx.userId ||
648
- ctx.sessionId ||
649
- ctx.conversationId ||
650
- ctx.requestId ||
651
- ctx.traceName ||
652
- ctx.metadata) {
653
- // Build customAttributes from top-level metadata string/number/boolean fields
654
- let customAttributes;
655
- if (ctx.metadata && typeof ctx.metadata === "object") {
656
- const metaObj = ctx.metadata;
657
- const attrs = {};
658
- for (const [k, v] of Object.entries(metaObj)) {
659
- if (typeof v === "string" ||
660
- typeof v === "number" ||
661
- typeof v === "boolean") {
662
- attrs[k] = v;
663
- }
664
- }
665
- if (Object.keys(attrs).length > 0) {
666
- customAttributes = attrs;
667
- }
668
- }
669
- return await new Promise((resolve, reject) => {
670
- setLangfuseContext({
671
- userId: typeof ctx.userId === "string" ? ctx.userId : null,
672
- sessionId: typeof ctx.sessionId === "string" ? ctx.sessionId : null,
673
- conversationId: typeof ctx.conversationId === "string"
674
- ? ctx.conversationId
675
- : null,
676
- requestId: typeof ctx.requestId === "string" ? ctx.requestId : null,
677
- traceName: typeof ctx.traceName === "string" ? ctx.traceName : null,
678
- metadata: ctx.metadata && typeof ctx.metadata === "object"
679
- ? ctx.metadata
680
- : null,
681
- ...(customAttributes !== undefined && { customAttributes }),
682
- }, async () => {
683
- try {
684
- callbackExecuted = true;
685
- const result = await callback();
686
- resolve(result);
687
- }
688
- catch (error) {
689
- reject(error);
690
- }
691
- });
692
- });
693
- }
694
- }
695
- catch (error) {
696
- if (callbackExecuted) {
697
- // Callback was executed inside Langfuse context but failed — do NOT retry
698
- // Re-throw to avoid double API calls and preserve error context
699
- throw error;
700
- }
701
- // Langfuse context setup itself failed — graceful degradation, run without context
702
- logger.warn("Failed to set Langfuse context from options", {
703
- error: error instanceof Error ? error.message : String(error),
704
- });
705
- }
706
- }
707
- return await callback();
783
+ if (!options.context || typeof options.context !== "object") {
784
+ return await callback();
785
+ }
786
+ const ctx = options.context;
787
+ const outer = getPropagatedAttributesFromContext(context.active());
788
+ const unset = (key, value) => typeof value === "string" && !outer[key] ? value : undefined;
789
+ return await setLangfuseContext({
790
+ userId: unset(LangfuseOtelSpanAttributes.TRACE_USER_ID, ctx.userId),
791
+ sessionId: unset(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, ctx.sessionId),
792
+ conversationId: typeof ctx.conversationId === "string"
793
+ ? ctx.conversationId
794
+ : undefined,
795
+ requestId: typeof ctx.requestId === "string" ? ctx.requestId : undefined,
796
+ traceName: unset(LangfuseOtelSpanAttributes.TRACE_NAME, ctx.traceName),
797
+ tags: Array.isArray(ctx.tags)
798
+ ? ctx.tags.filter((tag) => typeof tag === "string")
799
+ : undefined,
800
+ metadata: ctx.metadata && typeof ctx.metadata === "object"
801
+ ? ctx.metadata
802
+ : undefined,
803
+ }, callback);
708
804
  }
709
805
  createMetricsTraceContext() {
710
806
  // Attempt to reuse the active OTel trace context so Pipeline B spans
@@ -981,26 +1077,12 @@ export class NeuroLink {
981
1077
  const previousSink = logger.getEventEmitter();
982
1078
  const credentials = this.conversationMemoryConfig?.conversationMemory?.memory?.neurolink
983
1079
  ?.credentials ?? this.credentials;
984
- const hostLangfuse = this.observabilityConfig?.langfuse;
985
1080
  this.memoryCondenser = new NeuroLink({
986
1081
  ...(credentials ? { credentials } : {}),
987
1082
  conversationMemory: { enabled: false },
988
1083
  enableOrchestration: false,
989
1084
  ...(this.observabilityConfig
990
- ? {
991
- observability: {
992
- ...this.observabilityConfig,
993
- ...(hostLangfuse
994
- ? {
995
- langfuse: {
996
- ...hostLangfuse,
997
- autoDetectExternalProvider: true,
998
- skipLangfuseSpanProcessor: true,
999
- },
1000
- }
1001
- : {}),
1002
- },
1003
- }
1085
+ ? { observability: this.observabilityConfig }
1004
1086
  : {}),
1005
1087
  });
1006
1088
  if (previousSink) {
@@ -1113,6 +1195,7 @@ export class NeuroLink {
1113
1195
  * @throws {Error} When HITL configuration is invalid (if enabled)
1114
1196
  */
1115
1197
  observabilityConfig;
1198
+ telemetryHeld = false;
1116
1199
  metricsAggregator = new MetricsAggregator();
1117
1200
  analyticsService;
1118
1201
  /**
@@ -1135,6 +1218,9 @@ export class NeuroLink {
1135
1218
  }
1136
1219
  this.fileRegistry = new FileReferenceRegistry();
1137
1220
  this.observabilityConfig = config?.observability;
1221
+ if (this.observabilityConfig) {
1222
+ ensureTelemetry(this.observabilityConfig);
1223
+ }
1138
1224
  this.analyticsService = new AnalyticsService();
1139
1225
  // Initialize orchestration setting
1140
1226
  this.enableOrchestration = config?.enableOrchestration ?? false;
@@ -1230,7 +1316,6 @@ export class NeuroLink {
1230
1316
  this.skillsConfig = config.skills;
1231
1317
  this.registerSkillTools();
1232
1318
  }
1233
- this.initializeLangfuse(constructorId, constructorStartTime, constructorHrTimeStart);
1234
1319
  this.initializeMetricsListeners();
1235
1320
  this.logConstructorComplete(constructorId, constructorStartTime, constructorHrTimeStart);
1236
1321
  // Store auth config for lazy initialization
@@ -2195,20 +2280,14 @@ Current user's request: ${currentInput}`;
2195
2280
  logger.warn("Memory storage failed:", error);
2196
2281
  }
2197
2282
  };
2198
- // Carry the turn's identity across the setImmediate boundary so the
2199
- // condensation generate + redis spans don't orphan to "guest". Keep the
2200
- // ambient store when it survived (generate path — carries conversationId,
2201
- // metadata, …); re-establish from the caller only when it was lost (stream
2202
- // path, which fires after the caller consumed the stream).
2203
- const ambient = getLangfuseContext();
2204
- const wrappedMemoryWrite = !(ambient?.traceName || ambient?.userId) &&
2205
- (langfuseIdentity?.traceName || langfuseIdentity?.sessionId)
2283
+ const identifiedMemoryWrite = langfuseIdentity?.traceName || langfuseIdentity?.sessionId
2206
2284
  ? () => setLangfuseContext({
2207
2285
  userId,
2208
- sessionId: langfuseIdentity.sessionId ?? null,
2209
- traceName: langfuseIdentity.traceName ?? null,
2286
+ sessionId: langfuseIdentity.sessionId ?? undefined,
2287
+ traceName: langfuseIdentity.traceName ?? undefined,
2210
2288
  }, memoryWrite)
2211
- : runWithCurrentLangfuseContext(memoryWrite);
2289
+ : memoryWrite;
2290
+ const wrappedMemoryWrite = context.bind(context.active(), identifiedMemoryWrite);
2212
2291
  // Tracked from the moment it is scheduled, not from when setImmediate
2213
2292
  // fires: shutdown() typically runs in that gap, and a write it cannot
2214
2293
  // see is a write it cannot wait for. `memoryWrite` never rejects (it
@@ -2367,87 +2446,19 @@ Current user's request: ${currentInput}`;
2367
2446
  this.unregisterExternalMCPToolFromRegistry(event.toolName);
2368
2447
  });
2369
2448
  }
2370
- /**
2371
- * Initialize Langfuse observability for AI operations tracking
2372
- */
2373
- initializeLangfuse(constructorId, constructorStartTime, constructorHrTimeStart) {
2374
- const langfuseInitStartTime = process.hrtime.bigint();
2375
- try {
2376
- const langfuseConfig = this.observabilityConfig?.langfuse;
2377
- // Check if we should use external provider mode - bypass enabled check
2378
- const useExternalProvider = langfuseConfig?.autoDetectExternalProvider === true ||
2379
- langfuseConfig?.useExternalTracerProvider === true;
2380
- if (langfuseConfig?.enabled || useExternalProvider) {
2381
- logger.debug(`[NeuroLink] 📊 LOG_POINT_C019_LANGFUSE_INIT_START`, {
2382
- logPoint: "C019_LANGFUSE_INIT_START",
2383
- constructorId,
2384
- timestamp: new Date().toISOString(),
2385
- elapsedMs: Date.now() - constructorStartTime,
2386
- elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
2387
- langfuseInitStartTimeNs: langfuseInitStartTime.toString(),
2388
- message: "Starting Langfuse observability initialization",
2389
- });
2390
- // Initialize OpenTelemetry (sets defaults from config)
2391
- void initializeOpenTelemetry(langfuseConfig).catch((err) => {
2392
- logger.error("[NeuroLink] OpenTelemetry initialization failed", {
2393
- error: err instanceof Error ? err.message : String(err),
2394
- });
2395
- });
2396
- const healthStatus = getLangfuseHealthStatus();
2397
- const langfuseInitDurationNs = process.hrtime.bigint() - langfuseInitStartTime;
2398
- if (healthStatus.initialized &&
2399
- healthStatus.hasProcessor &&
2400
- healthStatus.isHealthy) {
2401
- logger.debug(`[NeuroLink] ✅ LOG_POINT_C020_LANGFUSE_INIT_SUCCESS`, {
2402
- logPoint: "C020_LANGFUSE_INIT_SUCCESS",
2403
- constructorId,
2404
- timestamp: new Date().toISOString(),
2405
- elapsedMs: Date.now() - constructorStartTime,
2406
- elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
2407
- langfuseInitDurationNs: langfuseInitDurationNs.toString(),
2408
- langfuseInitDurationMs: Number(langfuseInitDurationNs) / 1_000_000,
2409
- healthStatus,
2410
- message: "Langfuse observability initialized successfully",
2411
- });
2412
- }
2413
- else {
2414
- logger.warn(`[NeuroLink] ⚠️ LOG_POINT_C021_LANGFUSE_INIT_WARNING`, {
2415
- logPoint: "C021_LANGFUSE_INIT_WARNING",
2416
- constructorId,
2417
- timestamp: new Date().toISOString(),
2418
- elapsedMs: Date.now() - constructorStartTime,
2419
- elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
2420
- langfuseInitDurationNs: langfuseInitDurationNs.toString(),
2421
- healthStatus,
2422
- message: "Langfuse initialized but not healthy",
2423
- });
2424
- }
2425
- }
2426
- else {
2427
- logger.debug(`[NeuroLink] 🚫 LOG_POINT_C022_LANGFUSE_DISABLED`, {
2428
- logPoint: "C022_LANGFUSE_DISABLED",
2429
- constructorId,
2430
- timestamp: new Date().toISOString(),
2431
- elapsedMs: Date.now() - constructorStartTime,
2432
- elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
2433
- message: "Langfuse observability not enabled - skipping initialization",
2434
- });
2435
- }
2449
+ holdTelemetry() {
2450
+ if (!this.telemetryHeld) {
2451
+ this.telemetryHeld = true;
2452
+ acquireTelemetry(this.observabilityConfig);
2436
2453
  }
2437
- catch (error) {
2438
- const langfuseInitErrorDurationNs = process.hrtime.bigint() - langfuseInitStartTime;
2439
- logger.error(`[NeuroLink] ❌ LOG_POINT_C023_LANGFUSE_INIT_ERROR`, {
2440
- logPoint: "C023_LANGFUSE_INIT_ERROR",
2441
- constructorId,
2442
- timestamp: new Date().toISOString(),
2443
- elapsedMs: Date.now() - constructorStartTime,
2444
- elapsedNs: (process.hrtime.bigint() - constructorHrTimeStart).toString(),
2445
- langfuseInitDurationNs: langfuseInitErrorDurationNs.toString(),
2446
- errorMessage: error instanceof Error ? error.message : String(error),
2447
- errorStack: error instanceof Error ? error.stack : undefined,
2448
- message: "Langfuse observability initialization failed",
2449
- });
2454
+ }
2455
+ async releaseTelemetryHold() {
2456
+ if (!this.telemetryHeld) {
2457
+ await flushOpenTelemetry();
2458
+ return;
2450
2459
  }
2460
+ this.telemetryHeld = false;
2461
+ await releaseTelemetry();
2451
2462
  }
2452
2463
  /**
2453
2464
  * Log constructor completion with final state summary
@@ -3099,47 +3110,36 @@ Current user's request: ${currentInput}`;
3099
3110
  return this.observabilityConfig;
3100
3111
  }
3101
3112
  /**
3102
- * Check if Langfuse telemetry is enabled
3103
- * Centralized utility to avoid duplication across providers
3113
+ * @description Reports whether any tracing backend is active.
3114
+ * @returns {boolean} `true` when OpenTelemetry is initialized or a Langfuse exporter is running.
3104
3115
  */
3105
3116
  isTelemetryEnabled() {
3106
- // Check if observability config enables telemetry
3107
- if (this.observabilityConfig?.langfuse?.enabled) {
3108
- return true;
3109
- }
3110
- // Check if OpenTelemetry was initialized (by this or external app)
3111
- return isOpenTelemetryInitialized();
3117
+ return (isOpenTelemetryInitialized() || getLangfuseHealthStatus().hasProcessor);
3112
3118
  }
3113
3119
  /**
3114
- * Get comprehensive telemetry status including Langfuse, OTel, and exporter health
3120
+ * Get comprehensive telemetry status including Langfuse and OpenTelemetry state
3115
3121
  */
3116
3122
  getTelemetryStatus() {
3117
3123
  const langfuseConfig = this.observabilityConfig?.langfuse;
3118
- const otelConfig = this.observabilityConfig?.openTelemetry;
3124
+ const langfuseOptions = typeof langfuseConfig === "object" ? langfuseConfig : undefined;
3125
+ const { hasProcessor } = getLangfuseHealthStatus();
3119
3126
  return {
3120
- enabled: this.isTelemetryEnabled(),
3121
- langfuse: langfuseConfig
3127
+ enabled: isOpenTelemetryInitialized(),
3128
+ langfuse: langfuseConfig !== undefined || hasProcessor
3122
3129
  ? {
3123
- enabled: langfuseConfig.enabled ?? false,
3124
- baseUrl: langfuseConfig.baseUrl,
3125
- environment: langfuseConfig.environment,
3130
+ enabled: hasProcessor,
3131
+ baseUrl: langfuseOptions?.baseUrl ?? process.env.LANGFUSE_BASE_URL,
3132
+ environment: langfuseOptions?.environment ??
3133
+ process.env.LANGFUSE_TRACING_ENVIRONMENT,
3126
3134
  }
3127
3135
  : undefined,
3128
- openTelemetry: otelConfig
3136
+ openTelemetry: isOpenTelemetryInitialized() || process.env.OTEL_EXPORTER_OTLP_ENDPOINT
3129
3137
  ? {
3130
- enabled: otelConfig.enabled ?? false,
3131
- endpoint: otelConfig.endpoint,
3132
- serviceName: otelConfig.serviceName,
3138
+ enabled: isOpenTelemetryInitialized(),
3139
+ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
3140
+ serviceName: process.env.OTEL_SERVICE_NAME,
3133
3141
  }
3134
- : isOpenTelemetryInitialized() ||
3135
- process.env.OTEL_EXPORTER_OTLP_ENDPOINT
3136
- ? {
3137
- enabled: isOpenTelemetryInitialized(),
3138
- endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
3139
- serviceName: process.env.OTEL_SERVICE_NAME,
3140
- }
3141
- : undefined,
3142
- exporters: [],
3142
+ : undefined,
3143
3143
  };
3144
3144
  }
3145
3145
  /**
@@ -3229,27 +3229,7 @@ Current user's request: ${currentInput}`;
3229
3229
  * This method can be called externally to ensure Langfuse is properly initialized
3230
3230
  */
3231
3231
  async initializeLangfuseObservability() {
3232
- try {
3233
- const langfuseConfig = this.observabilityConfig?.langfuse;
3234
- if (langfuseConfig?.enabled) {
3235
- try {
3236
- await initializeOpenTelemetry(langfuseConfig);
3237
- }
3238
- catch (err) {
3239
- logger.error("[NeuroLink] OpenTelemetry initialization failed", {
3240
- error: err instanceof Error ? err.message : String(err),
3241
- });
3242
- return;
3243
- }
3244
- logger.debug("[NeuroLink] Langfuse observability initialized via public method");
3245
- }
3246
- else {
3247
- logger.debug("[NeuroLink] Langfuse not enabled, skipping initialization");
3248
- }
3249
- }
3250
- catch (error) {
3251
- logger.warn("[NeuroLink] Failed to initialize Langfuse observability:", error);
3252
- }
3232
+ this.holdTelemetry();
3253
3233
  }
3254
3234
  /**
3255
3235
  * Gracefully shutdown NeuroLink and all MCP connections
@@ -3275,8 +3255,7 @@ Current user's request: ${currentInput}`;
3275
3255
  logger.warn("[NeuroLink] Memory condenser shutdown failed:", error);
3276
3256
  }
3277
3257
  try {
3278
- await flushOpenTelemetry();
3279
- await shutdownOpenTelemetry();
3258
+ await this.releaseTelemetryHold();
3280
3259
  logger.debug("[NeuroLink] OpenTelemetry shutdown completed");
3281
3260
  }
3282
3261
  catch (error) {
@@ -3357,13 +3336,11 @@ Current user's request: ${currentInput}`;
3357
3336
  return totalCost > 0 ? totalCost : undefined;
3358
3337
  }
3359
3338
  /**
3360
- * Handle generation:end for Pipeline B spans + advanced analytics.
3361
- * When pipelineAHandled is set, skips Pipeline B spans but still tracks
3362
- * analytics so successful AI-SDK generate/stream calls are recorded.
3363
- * Stream analytics are owned here (not stream:complete) to avoid duplicates.
3339
+ * Handle generation:end: records advanced analytics for every generation
3340
+ * and the metrics span for a non-streamed one. A streamed generation gets
3341
+ * its span from stream:complete / stream:error instead.
3364
3342
  */
3365
3343
  handleGenerationEndMetrics(data) {
3366
- const skipPipelineBSpan = data.pipelineAHandled === true;
3367
3344
  try {
3368
3345
  const result = data.result;
3369
3346
  const usage = result?.usage;
@@ -3375,10 +3352,16 @@ Current user's request: ${currentInput}`;
3375
3352
  ? data.timestamp
3376
3353
  : Date.now();
3377
3354
  const traceCtx = this._metricsTraceContext;
3378
- let computedCost = typeof analytics?.cost === "number" && Number.isFinite(analytics.cost)
3355
+ const reportedCost = typeof analytics?.cost === "number" && Number.isFinite(analytics.cost)
3379
3356
  ? analytics.cost
3380
3357
  : undefined;
3381
- if (!skipPipelineBSpan) {
3358
+ const cost = reportedCost !== undefined && reportedCost > 0
3359
+ ? reportedCost
3360
+ : (this.estimateCostFromUsage(provider, model, usage) ??
3361
+ reportedCost);
3362
+ const aborted = data.aborted === true;
3363
+ const failed = !aborted && (data.success === false || !!data.error);
3364
+ if (data.streamed !== true) {
3382
3365
  let span = SpanSerializer.createGenerationSpan({
3383
3366
  provider,
3384
3367
  model,
@@ -3424,18 +3407,8 @@ Current user's request: ${currentInput}`;
3424
3407
  totalTokens: usage.total || (usage.input || 0) + (usage.output || 0),
3425
3408
  });
3426
3409
  }
3427
- if (analytics?.cost && analytics.cost > 0) {
3428
- span = SpanSerializer.enrichWithCost(span, {
3429
- totalCost: analytics.cost,
3430
- });
3431
- }
3432
- else {
3433
- const estimated = this.estimateCostFromUsage(provider, model, usage);
3434
- if (estimated !== undefined) {
3435
- span = SpanSerializer.enrichWithCost(span, {
3436
- totalCost: estimated,
3437
- });
3438
- }
3410
+ if (cost !== undefined && cost > 0) {
3411
+ span = SpanSerializer.enrichWithCost(span, { totalCost: cost });
3439
3412
  }
3440
3413
  const content = result?.content || result?.text;
3441
3414
  if (content) {
@@ -3447,50 +3420,27 @@ Current user's request: ${currentInput}`;
3447
3420
  }
3448
3421
  this.metricsAggregator.recordSpan(span);
3449
3422
  getMetricsAggregator().recordSpan(span);
3450
- const spanCost = span.attributes["ai.cost.total"];
3451
- if (typeof spanCost === "number" && spanCost > 0) {
3452
- computedCost = spanCost;
3453
- }
3454
- this.enqueueAnalyticsTrackRequest({
3455
- provider,
3456
- model,
3457
- userId: data.userId || data.user || undefined,
3458
- teamId: data.teamId || undefined,
3459
- department: data.department || undefined,
3460
- timestamp: eventTimestamp,
3461
- latency: responseTime,
3462
- inputTokens: usage?.input || 0,
3463
- outputTokens: usage?.output || 0,
3464
- totalTokens: usage?.total || (usage?.input || 0) + (usage?.output || 0),
3465
- cost: computedCost,
3466
- isError: span.status === SpanStatus.ERROR,
3467
- errorMessage: statusMessage,
3468
- qualityScore: parseAnalyticsQualityScore(data.qualityScore),
3469
- });
3470
- }
3471
- else {
3472
- // Pipeline A handled the observation — still record analytics so
3473
- // successful AI-SDK generate()/stream() calls are not silently dropped.
3474
- if (computedCost === undefined) {
3475
- computedCost = this.estimateCostFromUsage(provider, model, usage);
3476
- }
3477
- this.enqueueAnalyticsTrackRequest({
3478
- provider,
3479
- model,
3480
- userId: data.userId || data.user || undefined,
3481
- teamId: data.teamId || undefined,
3482
- department: data.department || undefined,
3483
- timestamp: eventTimestamp,
3484
- latency: responseTime,
3485
- inputTokens: usage?.input || 0,
3486
- outputTokens: usage?.output || 0,
3487
- totalTokens: usage?.total || (usage?.input || 0) + (usage?.output || 0),
3488
- cost: computedCost,
3489
- isError: data.success === false || Boolean(data.error),
3490
- errorMessage: data.error ? String(data.error) : undefined,
3491
- qualityScore: parseAnalyticsQualityScore(data.qualityScore),
3492
- });
3493
3423
  }
3424
+ this.enqueueAnalyticsTrackRequest({
3425
+ provider,
3426
+ model,
3427
+ userId: data.userId || data.user || undefined,
3428
+ teamId: data.teamId || undefined,
3429
+ department: data.department || undefined,
3430
+ timestamp: eventTimestamp,
3431
+ latency: responseTime,
3432
+ inputTokens: usage?.input || 0,
3433
+ outputTokens: usage?.output || 0,
3434
+ totalTokens: usage?.total || (usage?.input || 0) + (usage?.output || 0),
3435
+ cost,
3436
+ isError: failed,
3437
+ errorMessage: aborted
3438
+ ? "Generation aborted by client"
3439
+ : data.error
3440
+ ? String(data.error)
3441
+ : undefined,
3442
+ qualityScore: parseAnalyticsQualityScore(data.qualityScore),
3443
+ });
3494
3444
  }
3495
3445
  catch {
3496
3446
  // Non-blocking
@@ -3763,7 +3713,10 @@ Current user's request: ${currentInput}`;
3763
3713
  * @since 1.0.0
3764
3714
  */
3765
3715
  async generate(optionsOrPrompt) {
3766
- return logger.runInInstanceScope(this.logInstanceId, () => this.generateInInstanceScope(optionsOrPrompt));
3716
+ return logger.runInInstanceScope(this.logInstanceId, async () => {
3717
+ this.holdTelemetry();
3718
+ return this.generateInInstanceScope(optionsOrPrompt);
3719
+ });
3767
3720
  }
3768
3721
  /**
3769
3722
  * Body of {@link generate}, always entered through the instance log scope
@@ -3818,37 +3771,10 @@ Current user's request: ${currentInput}`;
3818
3771
  };
3819
3772
  }
3820
3773
  }
3821
- // Retrieve once at the public call boundary so fallback attempts reuse the
3822
- // same grounding block and internal preparation cannot inject it twice.
3823
- const groundingOptions = typeof optionsOrPrompt === "string"
3824
- ? { input: { text: optionsOrPrompt } }
3825
- : optionsOrPrompt;
3826
- const knowledgeOutcome = await this.retrieveKnowledgeGrounding(groundingOptions);
3827
- if (knowledgeOutcome?.ephemeralContext) {
3828
- const block = knowledgeOutcome.ephemeralContext.content;
3829
- optionsOrPrompt = {
3830
- ...groundingOptions,
3831
- systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(groundingOptions.systemPrompt, block),
3832
- };
3833
- }
3834
3774
  const startedAt = Date.now();
3775
+ const turnOptions = optionsOrPrompt;
3835
3776
  try {
3836
- const result = await this.runWithFallbackOrchestration(optionsOrPrompt, "generate", (opts) => {
3837
- // Capture root-ness before startActiveSpan makes generateSpan active.
3838
- // The actual guest-rescue stamp is deferred to executeGenerateRequest,
3839
- // AFTER prepareGenerateRequest merges auth/requestContext-derived
3840
- // identity into options.context — otherwise an auth:{token} caller
3841
- // with no pre-set context.userId would stamp the root span as guest.
3842
- const generateIsRoot = !trace.getSpan(context.active());
3843
- return tracers.sdk.startActiveSpan("neurolink.generate", { kind: SpanKind.INTERNAL }, (generateSpan) => this.executeGenerateWithMetricsContext(opts, generateSpan, generateIsRoot));
3844
- });
3845
- if (knowledgeOutcome) {
3846
- result.knowledge = knowledgeOutcome.metadata;
3847
- }
3848
- if (agentModeVersion) {
3849
- result.agentModeVersion = agentModeVersion;
3850
- }
3851
- return result;
3777
+ return await this.setLangfuseContextFromOptions(typeof turnOptions === "string" ? {} : turnOptions, () => tracers.sdk.startActiveSpan("generate", { kind: SpanKind.INTERNAL }, (turnSpan) => this.runGenerateTurn(turnOptions, agentModeVersion, turnSpan)));
3852
3778
  }
3853
3779
  catch (error) {
3854
3780
  // Lifecycle middleware (wrapGenerate.catch in builtin/lifecycle.ts)
@@ -3863,6 +3789,49 @@ Current user's request: ${currentInput}`;
3863
3789
  throw error;
3864
3790
  }
3865
3791
  }
3792
+ async runGenerateTurn(optionsOrPrompt, agentModeVersion, turnSpan) {
3793
+ try {
3794
+ const groundingOptions = typeof optionsOrPrompt === "string"
3795
+ ? { input: { text: optionsOrPrompt } }
3796
+ : optionsOrPrompt;
3797
+ const knowledgeOutcome = await this.retrieveKnowledgeGrounding(groundingOptions);
3798
+ if (knowledgeOutcome?.ephemeralContext) {
3799
+ const block = knowledgeOutcome.ephemeralContext.content;
3800
+ optionsOrPrompt = {
3801
+ ...groundingOptions,
3802
+ systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(groundingOptions.systemPrompt, block),
3803
+ };
3804
+ }
3805
+ startTurn(turnSpan, optionsOrPrompt);
3806
+ const result = await this.runWithFallbackOrchestration(optionsOrPrompt, "generate", (opts) => tracers.sdk.startActiveSpan("generate.attempt", { kind: SpanKind.INTERNAL }, (attemptSpan) => {
3807
+ observe(attemptSpan, "span", {});
3808
+ return this.executeGenerateWithMetricsContext(opts, attemptSpan);
3809
+ }));
3810
+ if (knowledgeOutcome) {
3811
+ result.knowledge = knowledgeOutcome.metadata;
3812
+ }
3813
+ if (agentModeVersion) {
3814
+ result.agentModeVersion = agentModeVersion;
3815
+ }
3816
+ observeTurn(turnSpan, {
3817
+ output: assistantMessages({
3818
+ text: result.content,
3819
+ toolCalls: result.toolExecutions,
3820
+ }),
3821
+ metadata: { model: result.model },
3822
+ level: observationLevel(result.finishReason),
3823
+ });
3824
+ turnSpan.setStatus({ code: SpanStatusCode.OK });
3825
+ return result;
3826
+ }
3827
+ catch (error) {
3828
+ failTurnSpan(turnSpan, error instanceof Error ? error : new Error(String(error)));
3829
+ throw error;
3830
+ }
3831
+ finally {
3832
+ turnSpan.end();
3833
+ }
3834
+ }
3866
3835
  async fireConsumerOnErrorIfNotFired(optionsOrPrompt, error, startedAt) {
3867
3836
  if (hasLifecycleErrorFired(error)) {
3868
3837
  return;
@@ -4053,17 +4022,14 @@ Current user's request: ${currentInput}`;
4053
4022
  return { error };
4054
4023
  }
4055
4024
  }
4056
- async executeGenerateWithMetricsContext(optionsOrPrompt, generateSpan, isRootSpan) {
4057
- return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeGenerateRequest(optionsOrPrompt, generateSpan, isRootSpan));
4025
+ async executeGenerateWithMetricsContext(optionsOrPrompt, generateSpan) {
4026
+ return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeGenerateRequest(optionsOrPrompt, generateSpan));
4058
4027
  }
4059
- async executeGenerateRequest(optionsOrPrompt, generateSpan, isRootSpan) {
4028
+ async executeGenerateRequest(optionsOrPrompt, generateSpan) {
4060
4029
  let resolvedOptions;
4061
4030
  try {
4062
4031
  const { options, originalPrompt } = await this.prepareGenerateRequest(optionsOrPrompt, generateSpan);
4063
4032
  resolvedOptions = options;
4064
- // Stamp now that prepareGenerateRequest has merged any auth/requestContext
4065
- // identity into options.context (see capture of isRootSpan in generate()).
4066
- stampGuestRescueIdentity(generateSpan, options.context, isRootSpan);
4067
4033
  const earlyResult = await this.maybeHandleEarlyGenerateResult(options, generateSpan);
4068
4034
  if (earlyResult) {
4069
4035
  generateSpan.setStatus({ code: SpanStatusCode.OK });
@@ -4081,14 +4047,9 @@ Current user's request: ${currentInput}`;
4081
4047
  return result;
4082
4048
  }
4083
4049
  catch (error) {
4084
- // Match the inner-span discrimination: client aborts are user-initiated
4085
- // cancellations, not faults. Mark with finishReason=aborted and skip
4086
- // ERROR status so ContextEnricher routes the outer trace to
4087
- // langfuse.level=WARNING (matches Curator telemetry-gaps Issue 5a). All
4088
- // other errors keep the existing ERROR status + recordException pair.
4089
4050
  if (isAbortError(error)) {
4090
4051
  generateSpan.setAttribute("ai.finishReason", "aborted");
4091
- generateSpan.setAttribute("neurolink.aborted", true);
4052
+ generateSpan.setAttribute("aborted", true);
4092
4053
  }
4093
4054
  else {
4094
4055
  generateSpan.recordException(error instanceof Error ? error : new Error(String(error)));
@@ -4099,9 +4060,9 @@ Current user's request: ${currentInput}`;
4099
4060
  }
4100
4061
  // G7 fix: Distinguish context overflow errors with dedicated attributes
4101
4062
  if (error instanceof ContextBudgetExceededError) {
4102
- generateSpan.setAttribute("neurolink.error.type", "context_overflow");
4103
- generateSpan.setAttribute("neurolink.context.estimated_tokens", error.estimatedTokens);
4104
- generateSpan.setAttribute("neurolink.context.available_tokens", error.availableTokens);
4063
+ generateSpan.setAttribute("error.type", "context_overflow");
4064
+ generateSpan.setAttribute("context.estimated_tokens", error.estimatedTokens);
4065
+ generateSpan.setAttribute("context.available_tokens", error.availableTokens);
4105
4066
  }
4106
4067
  this.emitGenerateErrorEvent((resolvedOptions ?? optionsOrPrompt), error);
4107
4068
  throw error;
@@ -4130,12 +4091,12 @@ Current user's request: ${currentInput}`;
4130
4091
  this._disableToolCacheForCurrentRequest = !!options.disableToolCache;
4131
4092
  this._toolCacheKeysServedThisRequest = new Set();
4132
4093
  this._generationTurnActive = true;
4133
- generateSpan.setAttribute("neurolink.provider", options.provider || "default");
4134
- generateSpan.setAttribute("neurolink.model", options.model || "default");
4135
- generateSpan.setAttribute("neurolink.input_length", typeof optionsOrPrompt === "string"
4094
+ generateSpan.setAttribute("sdk.provider", options.provider || "default");
4095
+ generateSpan.setAttribute("sdk.model", options.model || "default");
4096
+ generateSpan.setAttribute("input_length", typeof optionsOrPrompt === "string"
4136
4097
  ? optionsOrPrompt.length
4137
4098
  : options.input?.text?.length || 0);
4138
- generateSpan.setAttribute("neurolink.has_tools", !!(options.tools && Object.keys(options.tools).length > 0));
4099
+ generateSpan.setAttribute("has_tools", !!(options.tools && Object.keys(options.tools).length > 0));
4139
4100
  // Ensure options.input is always an object — callers may omit it for
4140
4101
  // media-only modes (avatar / music / video) or STT flows. Downstream code
4141
4102
  // accesses it unconditionally, so we guarantee a non-null object here and
@@ -4243,11 +4204,11 @@ Current user's request: ${currentInput}`;
4243
4204
  logger.warn("[NeuroLink] STT audio preprocessing is not supported with PPT mode; audio will be ignored");
4244
4205
  }
4245
4206
  const pptResult = await this.generateWithPPT(options);
4246
- generateSpan.setAttribute("neurolink.output_length", pptResult.content?.length ?? 0);
4207
+ generateSpan.setAttribute("output_length", pptResult.content?.length ?? 0);
4247
4208
  if (pptResult.analytics) {
4248
- generateSpan.setAttribute("neurolink.tokens.input", pptResult.analytics.tokenUsage?.input ?? 0);
4249
- generateSpan.setAttribute("neurolink.tokens.output", pptResult.analytics.tokenUsage?.output ?? 0);
4250
- generateSpan.setAttribute("neurolink.cost", pptResult.analytics.cost ?? 0);
4209
+ generateSpan.setAttribute("tokens.input", pptResult.analytics.tokenUsage?.input ?? 0);
4210
+ generateSpan.setAttribute("tokens.output", pptResult.analytics.tokenUsage?.output ?? 0);
4211
+ generateSpan.setAttribute("cost", pptResult.analytics.cost ?? 0);
4251
4212
  }
4252
4213
  generateSpan.setStatus({ code: SpanStatusCode.OK });
4253
4214
  return pptResult;
@@ -4802,10 +4763,6 @@ Current user's request: ${currentInput}`;
4802
4763
  // generate() today. Declaring them is a public-type decision and is
4803
4764
  // deliberately left out of this fix.
4804
4765
  thinkingConfig: options.thinkingConfig,
4805
- // Lifecycle callbacks must reach the provider so non-AI-SDK paths
4806
- // (Vertex's native @google/genai, native Bedrock, Ollama, etc.) can
4807
- // invoke them directly. Pipeline A also still receives them via the
4808
- // wrapped middleware config set by applyGenerateLifecycleMiddleware.
4809
4766
  onFinish: options.onFinish,
4810
4767
  onError: options.onError,
4811
4768
  };
@@ -4934,12 +4891,6 @@ Current user's request: ${currentInput}`;
4934
4891
  });
4935
4892
  }
4936
4893
  }
4937
- // Skip the top-level `generation:end` emission when the provider already
4938
- // emitted it from its native generate path (Vertex / Google AI Studio).
4939
- // Without this guard, native-path providers would surface TWO events
4940
- // per generate call (Pipeline A path: 1 from provider native + 1 here;
4941
- // standard AI-SDK path: 1 here). The observability suite asserts on
4942
- // emission count and would (and did) fail.
4943
4894
  const nativeAlreadyEmitted = !!textResult._generationEndEmitted;
4944
4895
  if (!nativeAlreadyEmitted) {
4945
4896
  this.emitter.emit("generation:end", {
@@ -4957,10 +4908,6 @@ Current user's request: ${currentInput}`;
4957
4908
  options.prompt,
4958
4909
  temperature: textOptions.temperature,
4959
4910
  maxTokens: textOptions.maxTokens,
4960
- // A2 fix: Signal that Pipeline A (AI SDK → @langfuse/otel) already
4961
- // creates a GENERATION observation for this call. The generation:end
4962
- // listener should skip creating a duplicate Pipeline B span.
4963
- pipelineAHandled: true,
4964
4911
  });
4965
4912
  }
4966
4913
  this.emitter.emit("response:end", textResult.content || "");
@@ -5068,18 +5015,18 @@ Current user's request: ${currentInput}`;
5068
5015
  this._sessionCostUsd += generateResult.analytics.cost;
5069
5016
  }
5070
5017
  this.scheduleGenerateMemoryStorage(options, originalPrompt, generateResult);
5071
- generateSpan.setAttribute("neurolink.output_length", generateResult.content?.length || 0);
5072
- generateSpan.setAttribute("neurolink.tokens.input", generateResult.usage?.input || 0);
5073
- generateSpan.setAttribute("neurolink.tokens.output", generateResult.usage?.output || 0);
5074
- generateSpan.setAttribute("neurolink.finish_reason", generateResult.finishReason || "unknown");
5018
+ generateSpan.setAttribute("output_length", generateResult.content?.length || 0);
5019
+ generateSpan.setAttribute("tokens.input", generateResult.usage?.input || 0);
5020
+ generateSpan.setAttribute("tokens.output", generateResult.usage?.output || 0);
5021
+ generateSpan.setAttribute("finish_reason", generateResult.finishReason || "unknown");
5075
5022
  // G3 fix: Record step count and whether max steps was reached
5076
5023
  // Read steps from the raw provider result (textResult), not the flattened DTO
5077
5024
  const stepCount = textResult?.steps?.length ?? 1;
5078
5025
  const maxSteps = options.maxSteps ?? 200; // DEFAULT_MAX_STEPS
5079
- generateSpan.setAttribute("neurolink.step_count", stepCount);
5080
- generateSpan.setAttribute("neurolink.max_steps_reached", stepCount >= maxSteps);
5081
- generateSpan.setAttribute("neurolink.result_provider", generateResult.provider || "unknown");
5082
- generateSpan.setAttribute("neurolink.result_model", generateResult.model || "unknown");
5026
+ generateSpan.setAttribute("step_count", stepCount);
5027
+ generateSpan.setAttribute("max_steps_reached", stepCount >= maxSteps);
5028
+ generateSpan.setAttribute("result_provider", generateResult.provider || "unknown");
5029
+ generateSpan.setAttribute("result_model", generateResult.model || "unknown");
5083
5030
  generateSpan.setAttribute("generate.retry_count", generateResult.retries?.count || 0);
5084
5031
  generateSpan.setStatus({ code: SpanStatusCode.OK });
5085
5032
  return generateResult;
@@ -5200,8 +5147,8 @@ Current user's request: ${currentInput}`;
5200
5147
  ...musicOptions,
5201
5148
  prompt: musicOptions.prompt ?? options.input?.text ?? "",
5202
5149
  });
5203
- generateSpan.setAttribute("neurolink.music.provider", providerName);
5204
- generateSpan.setAttribute("neurolink.music.bytes", musicResult.size);
5150
+ generateSpan.setAttribute("music.provider", providerName);
5151
+ generateSpan.setAttribute("music.bytes", musicResult.size);
5205
5152
  generateSpan.setStatus({ code: SpanStatusCode.OK });
5206
5153
  return {
5207
5154
  content: `Music generated (${providerName}, ${musicResult.size} bytes, ${musicResult.format}).`,
@@ -5234,8 +5181,8 @@ Current user's request: ${currentInput}`;
5234
5181
  registerDefaultAvatarHandlers();
5235
5182
  const { AvatarProcessor } = await import("./utils/avatarProcessor.js");
5236
5183
  const avatarResult = await AvatarProcessor.generate(providerName, avatarOptions);
5237
- generateSpan.setAttribute("neurolink.avatar.provider", providerName);
5238
- generateSpan.setAttribute("neurolink.avatar.bytes", avatarResult.size);
5184
+ generateSpan.setAttribute("avatar.provider", providerName);
5185
+ generateSpan.setAttribute("avatar.bytes", avatarResult.size);
5239
5186
  generateSpan.setStatus({ code: SpanStatusCode.OK });
5240
5187
  return {
5241
5188
  content: `Avatar generated (${providerName}, ${avatarResult.size} bytes, ${avatarResult.format}).`,
@@ -5537,7 +5484,10 @@ Current user's request: ${currentInput}`;
5537
5484
  * 5. Store conversation turn for future context
5538
5485
  */
5539
5486
  async generateTextInternal(options) {
5540
- return tracers.sdk.startActiveSpan("neurolink.generateTextInternal", { kind: SpanKind.INTERNAL }, (internalSpan) => this.executeGenerateTextInternalWithSpan(options, internalSpan));
5487
+ const internalSpan = tracers.sdk.startSpan("generateTextInternal", {
5488
+ kind: SpanKind.INTERNAL,
5489
+ });
5490
+ return this.executeGenerateTextInternalWithSpan(options, internalSpan);
5541
5491
  }
5542
5492
  async executeGenerateTextInternalWithSpan(options, internalSpan) {
5543
5493
  return this.conversationEventContext.run(Symbol("generate-conversation"), () => this.executeGenerateTextInternalInEventScope(options, internalSpan));
@@ -5549,24 +5499,19 @@ Current user's request: ${currentInput}`;
5549
5499
  ...this.initializeGenerateTextInternalContext(options),
5550
5500
  eventSequence,
5551
5501
  };
5552
- internalSpan.setAttribute("neurolink.request_id", context.requestId);
5553
- internalSpan.setAttribute("neurolink.has_conversation_memory", !!this.conversationMemory);
5554
- internalSpan.setAttribute("neurolink.provider", options.provider || "auto");
5555
- internalSpan.setAttribute("neurolink.model", options.model || "default");
5502
+ internalSpan.setAttribute("request_id", context.requestId);
5503
+ internalSpan.setAttribute("has_conversation_memory", !!this.conversationMemory);
5504
+ internalSpan.setAttribute("sdk.provider", options.provider || "auto");
5505
+ internalSpan.setAttribute("sdk.model", options.model || "default");
5556
5506
  this.logGenerateTextInternalStart(context.generateInternalId, context.generateInternalStartTime, context.generateInternalHrTimeStart, options, context.functionTag);
5557
5507
  this.emitGenerationStartEvents(options);
5558
5508
  return await this.runGenerateTextInternalFlow(options, internalSpan, context);
5559
5509
  }
5560
5510
  catch (error) {
5561
5511
  // Client aborts are user-initiated cancellations, not system faults.
5562
- // Setting status=ERROR forces Langfuse to level=ERROR (see
5563
- // ContextEnricher.onEnd → instrumentation.ts:691). Instead leave status
5564
- // unset and stamp ai.finishReason=aborted so applyNonErrorLangfuseLevel
5565
- // maps it to level=WARNING with the canonical "Generation aborted by
5566
- // client" status_message. Matches Curator telemetry-gaps Issue 5a.
5567
5512
  if (isAbortError(error)) {
5568
5513
  internalSpan.setAttribute("ai.finishReason", "aborted");
5569
- internalSpan.setAttribute("neurolink.aborted", true);
5514
+ internalSpan.setAttribute("aborted", true);
5570
5515
  }
5571
5516
  else {
5572
5517
  internalSpan.recordException(error instanceof Error ? error : new Error(String(error)));
@@ -5688,10 +5633,10 @@ Current user's request: ${currentInput}`;
5688
5633
  if (path === "direct") {
5689
5634
  this.emitter.emit("message", "Text generation completed successfully");
5690
5635
  }
5691
- internalSpan.setAttribute("neurolink.path", path);
5692
- internalSpan.setAttribute("neurolink.tokens.input", result.usage?.input || 0);
5693
- internalSpan.setAttribute("neurolink.tokens.output", result.usage?.output || 0);
5694
- internalSpan.setAttribute("neurolink.result_provider", result.provider || "unknown");
5636
+ internalSpan.setAttribute("path", path);
5637
+ internalSpan.setAttribute("tokens.input", result.usage?.input || 0);
5638
+ internalSpan.setAttribute("tokens.output", result.usage?.output || 0);
5639
+ internalSpan.setAttribute("result_provider", result.provider || "unknown");
5695
5640
  internalSpan.setStatus({ code: SpanStatusCode.OK });
5696
5641
  return result;
5697
5642
  }
@@ -7417,6 +7362,7 @@ Current user's request: ${currentInput}`;
7417
7362
  async stream(options) {
7418
7363
  const eventScope = Symbol("stream-conversation");
7419
7364
  return this.conversationEventContext.run(eventScope, () => logger.runInInstanceScope(this.logInstanceId, async () => {
7365
+ this.holdTelemetry();
7420
7366
  const result = await this.streamInInstanceScope(options);
7421
7367
  return preserveLiveStreamAccessors(result, {
7422
7368
  ...result,
@@ -7427,24 +7373,22 @@ Current user's request: ${currentInput}`;
7427
7373
  /** Re-enter request ownership when the caller drives a deferred async iterator. */
7428
7374
  bindConversationEventStream(stream, eventScope) {
7429
7375
  const scope = this.conversationEventContext;
7430
- return (async function* () {
7431
- const iterator = stream[Symbol.asyncIterator]();
7432
- try {
7433
- while (true) {
7434
- const next = await scope.run(eventScope, () => iterator.next());
7435
- if (next.done) {
7436
- return;
7437
- }
7438
- yield next.value;
7439
- }
7440
- }
7441
- finally {
7442
- const close = iterator.return?.bind(iterator);
7443
- if (close) {
7444
- await scope.run(eventScope, () => close());
7445
- }
7446
- }
7447
- })();
7376
+ const iterator = stream[Symbol.asyncIterator]();
7377
+ const bound = {
7378
+ next: () => scope.run(eventScope, () => iterator.next()),
7379
+ async return() {
7380
+ await scope.run(eventScope, async () => iterator.return?.());
7381
+ return { done: true, value: undefined };
7382
+ },
7383
+ async throw(error) {
7384
+ await scope.run(eventScope, async () => iterator.return?.());
7385
+ throw error;
7386
+ },
7387
+ [Symbol.asyncIterator]() {
7388
+ return bound;
7389
+ },
7390
+ };
7391
+ return bound;
7448
7392
  }
7449
7393
  /**
7450
7394
  * Body of {@link stream}, always entered through the instance log scope.
@@ -7501,26 +7445,9 @@ Current user's request: ${currentInput}`;
7501
7445
  }
7502
7446
  bindFileToolRoots(options, this.resolveFileToolRoots(options.toolRoots));
7503
7447
  const startedAt = Date.now();
7504
- // Retrieve once before provider/model fallback orchestration. Every fallback
7505
- // receives the same explicitly enriched options, without the retrieval helper
7506
- // mutating its input or injecting the same block more than once.
7507
- const knowledgeOutcome = await this.retrieveKnowledgeGrounding(options);
7508
- if (knowledgeOutcome?.ephemeralContext) {
7509
- const block = knowledgeOutcome.ephemeralContext.content;
7510
- options = {
7511
- ...options,
7512
- systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(options.systemPrompt, block),
7513
- };
7514
- }
7448
+ const turnOptions = options;
7515
7449
  try {
7516
- const result = await this.streamWithIterationFallback(options);
7517
- if (knowledgeOutcome) {
7518
- result.knowledge = knowledgeOutcome.metadata;
7519
- }
7520
- if (agentModeVersion) {
7521
- result.agentModeVersion = agentModeVersion;
7522
- }
7523
- return result;
7450
+ return await this.setLangfuseContextFromOptions(turnOptions, () => this.runStreamTurn(turnOptions, agentModeVersion));
7524
7451
  }
7525
7452
  catch (error) {
7526
7453
  // Mirror generate(): fire consumer onError for failures that
@@ -7534,6 +7461,42 @@ Current user's request: ${currentInput}`;
7534
7461
  throw error;
7535
7462
  }
7536
7463
  }
7464
+ async runStreamTurn(options, agentModeVersion) {
7465
+ const turnSpan = tracers.sdk.startSpan("stream", {
7466
+ kind: SpanKind.INTERNAL,
7467
+ });
7468
+ const turnContext = trace.setSpan(context.active(), turnSpan);
7469
+ let result;
7470
+ try {
7471
+ result = await context.with(turnContext, async () => {
7472
+ const knowledgeOutcome = await this.retrieveKnowledgeGrounding(options);
7473
+ const grounded = knowledgeOutcome?.ephemeralContext
7474
+ ? {
7475
+ ...options,
7476
+ systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(options.systemPrompt, knowledgeOutcome.ephemeralContext.content),
7477
+ }
7478
+ : options;
7479
+ startTurn(turnSpan, grounded);
7480
+ const streamed = await this.streamWithIterationFallback(grounded);
7481
+ if (knowledgeOutcome) {
7482
+ streamed.knowledge = knowledgeOutcome.metadata;
7483
+ }
7484
+ if (agentModeVersion) {
7485
+ streamed.agentModeVersion = agentModeVersion;
7486
+ }
7487
+ return streamed;
7488
+ });
7489
+ }
7490
+ catch (error) {
7491
+ failTurnSpan(turnSpan, error instanceof Error ? error : new Error(String(error)));
7492
+ turnSpan.end();
7493
+ throw error;
7494
+ }
7495
+ return preserveLiveStreamAccessors(result, {
7496
+ ...result,
7497
+ stream: traceTurnStream(result, turnSpan, turnContext),
7498
+ });
7499
+ }
7537
7500
  /**
7538
7501
  * Curator P2-3 / Reviewer Finding #2: stream-fallback that also covers
7539
7502
  * errors thrown during async iteration (e.g. LiteLLM throwing inside
@@ -7665,11 +7628,11 @@ Current user's request: ${currentInput}`;
7665
7628
  async executeStreamRequest(options) {
7666
7629
  // Dynamic argument resolution — resolve any function-valued options before downstream use
7667
7630
  await this.resolveDynamicOptions(options);
7668
- const streamSpan = tracers.sdk.startSpan("neurolink.stream", {
7631
+ const streamSpan = tracers.sdk.startSpan("stream.attempt", {
7669
7632
  kind: SpanKind.INTERNAL,
7670
7633
  attributes: {
7671
7634
  [ATTR.NL_PROVIDER]: options.provider || "default",
7672
- [ATTR.GEN_AI_MODEL]: options.model || "default",
7635
+ ...(options.model && { [ATTR.NL_MODEL]: options.model }),
7673
7636
  [ATTR.NL_INPUT_LENGTH]: options.input?.text?.length || 0,
7674
7637
  // Count registered custom tools too — chat hosts put their MCP tools
7675
7638
  // in the registry, so options.tools alone under-reports.
@@ -7679,12 +7642,7 @@ Current user's request: ${currentInput}`;
7679
7642
  [ATTR.NL_STREAM_MODE]: true,
7680
7643
  },
7681
7644
  });
7682
- // streamSpan isn't active yet, so context.active() is its parent — empty =
7683
- // root. Capture root-ness here, but defer the actual guest-rescue stamp to
7684
- // after validateStreamRequestOptions merges auth/requestContext identity
7685
- // into options.context (below) — otherwise an auth:{token} caller with no
7686
- // pre-set context.userId would stamp the root span as guest.
7687
- const streamIsRoot = !trace.getSpan(context.active());
7645
+ observe(streamSpan, "span", {});
7688
7646
  const spanStartTime = Date.now();
7689
7647
  this._disableToolCacheForCurrentRequest = !!options.disableToolCache;
7690
7648
  this._toolCacheKeysServedThisRequest = new Set();
@@ -7729,8 +7687,6 @@ Current user's request: ${currentInput}`;
7729
7687
  const originalPrompt = options.input?.text ?? "";
7730
7688
  options.fileRegistry = this.fileRegistry;
7731
7689
  await this.validateStreamRequestOptions(options, startTime);
7732
- // options.context now carries any auth/requestContext-derived identity.
7733
- stampGuestRescueIdentity(streamSpan, options.context, streamIsRoot);
7734
7690
  const workflowResult = await this.maybeHandleWorkflowStreamRequest({
7735
7691
  options,
7736
7692
  startTime,
@@ -7780,19 +7736,23 @@ Current user's request: ${currentInput}`;
7780
7736
  resolveStreamTtsAudio = resolve;
7781
7737
  })
7782
7738
  : undefined;
7783
- const streamResult = await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.runStandardStreamRequest({
7784
- options,
7785
- streamSpan,
7786
- spanStartTime,
7787
- startTime,
7788
- hrTimeStart,
7789
- streamId,
7790
- originalPrompt,
7791
- ttsResolver: resolveStreamTtsAudio,
7792
- ttsMetadataSink: (metadata) => {
7793
- streamTtsMetadata = metadata;
7794
- },
7795
- })));
7739
+ const streamResult = await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, async () => {
7740
+ const attempt = await this.runStandardStreamRequest({
7741
+ options,
7742
+ streamSpan,
7743
+ spanStartTime,
7744
+ startTime,
7745
+ hrTimeStart,
7746
+ streamId,
7747
+ originalPrompt,
7748
+ ttsResolver: resolveStreamTtsAudio,
7749
+ ttsMetadataSink: (metadata) => {
7750
+ streamTtsMetadata = metadata;
7751
+ },
7752
+ });
7753
+ attempt.stream = drainInContext(attempt.stream, context.active());
7754
+ return attempt;
7755
+ }));
7796
7756
  if (streamSttTranscription) {
7797
7757
  streamResult.transcription = streamSttTranscription;
7798
7758
  }
@@ -8358,7 +8318,7 @@ Current user's request: ${currentInput}`;
8358
8318
  self._disableToolCacheForCurrentRequest = false;
8359
8319
  self._toolCacheKeysServedThisRequest = new Set();
8360
8320
  self._generationTurnActive = false;
8361
- params.streamSpan.setAttribute("neurolink.response_time_ms", Date.now() - params.spanStartTime);
8321
+ params.streamSpan.setAttribute("response_time_ms", Date.now() - params.spanStartTime);
8362
8322
  params.streamSpan.end();
8363
8323
  }
8364
8324
  })();
@@ -8411,14 +8371,14 @@ Current user's request: ${currentInput}`;
8411
8371
  const mcpStreamOutcome = await this.createMCPStream(enhancedOptions);
8412
8372
  const { stream: mcpStream } = mcpStreamOutcome;
8413
8373
  const providerName = mcpStreamOutcome.provider ?? "unknown";
8414
- let streamedTTSResult;
8374
+ const tts = {};
8415
8375
  const { stream: incrementalStream, ttsMetadata: streamTtsMetadata } = await this.createIncrementalTTSStream({
8416
8376
  stream: mcpStream,
8417
8377
  ttsOptions: enhancedOptions.tts,
8418
8378
  providerName,
8419
8379
  fallbackProvider: enhancedOptions.provider,
8420
8380
  onComplete: (result) => {
8421
- streamedTTSResult = result;
8381
+ tts.result = result;
8422
8382
  },
8423
8383
  });
8424
8384
  ttsMetadataSink?.(streamTtsMetadata);
@@ -8450,8 +8410,6 @@ Current user's request: ${currentInput}`;
8450
8410
  const currentToolCalls = () => streamState.toolCalls ?? mcpStreamOutcome.toolCalls ?? [];
8451
8411
  const currentToolResults = () => streamState.toolResults ?? mcpStreamOutcome.toolResults ?? [];
8452
8412
  streamSpan.setAttribute(ATTR.NL_PROVIDER, providerName || "unknown");
8453
- let accumulatedContent = "";
8454
- let chunkCount = 0;
8455
8413
  const { eventSequence, cleanup: cleanupListeners } = this.setupStreamEventListeners();
8456
8414
  const metadata = {
8457
8415
  fallbackAttempted: false,
@@ -8460,312 +8418,32 @@ Current user's request: ${currentInput}`;
8460
8418
  fallbackProvider: undefined,
8461
8419
  fallbackModel: undefined,
8462
8420
  };
8463
- const self = this;
8464
8421
  const streamStartTime = Date.now();
8465
8422
  const sessionId = enhancedOptions.context
8466
8423
  ?.sessionId;
8467
- const processedStream = (async function* () {
8468
- let streamError;
8469
- // Curator P2-4: hoist `resolvedUsage` so the finally block can emit a
8470
- // single `generation:end` event with cost data. Cost listeners
8471
- // subscribe here; previously the stream path never fired it.
8472
- let resolvedUsage;
8473
- // Reviewer follow-up: track *non-sentinel output chunks* (text,
8474
- // audio, image — anything the SDK considers real output) so the
8475
- // fallback gate fires only when the stream produced nothing
8476
- // useful. Counting only text content here would have spuriously
8477
- // triggered fallback for valid audio-only (Google Live) and
8478
- // image-only streams. The sentinel is the only thing we exclude
8479
- // — that path can mask real provider failures (DNS, auth,
8480
- // retry-exhaustion) that AI SDK rejects with
8481
- // NoOutputGeneratedError, and we want fallback to fire there.
8482
- let realOutputChunks = 0;
8483
- try {
8484
- for await (const chunk of incrementalStream) {
8485
- chunkCount++;
8486
- const isNoOutputSentinel = chunk !== null &&
8487
- typeof chunk === "object" &&
8488
- "metadata" in chunk &&
8489
- chunk.metadata
8490
- ?.noOutput === true;
8491
- const hasTextContent = chunk &&
8492
- "content" in chunk &&
8493
- typeof chunk.content === "string" &&
8494
- chunk.content.length > 0;
8495
- const hasMediaPayload = chunk !== null &&
8496
- typeof chunk === "object" &&
8497
- "type" in chunk &&
8498
- (chunk.type === "audio" ||
8499
- chunk.type === "tts_audio" ||
8500
- chunk.type === "image");
8501
- if (!isNoOutputSentinel && (hasTextContent || hasMediaPayload)) {
8502
- realOutputChunks++;
8503
- }
8504
- if (chunk &&
8505
- "content" in chunk &&
8506
- typeof chunk.content === "string") {
8507
- accumulatedContent += chunk.content;
8508
- self.emitter.emit("response:chunk", chunk.content);
8509
- self.emitter.emit("stream:chunk", {
8510
- type: "stream:chunk",
8511
- content: chunk.content,
8512
- metadata: {
8513
- chunkIndex: chunkCount,
8514
- totalLength: accumulatedContent.length,
8515
- ...(isNoOutputSentinel && { noOutput: true }),
8516
- },
8517
- timestamp: Date.now(),
8518
- });
8519
- }
8520
- yield chunk;
8521
- }
8522
- // Reviewer follow-up: fire fallback when no *non-sentinel*
8523
- // output was produced — sentinel-only and truly empty streams
8524
- // both qualify, but media-only streams (audio/image) do not.
8525
- //
8526
- // fallbackOnMaxSteps: false exempts one no-output shape — a turn
8527
- // the provider reports as ended at the caller's own maxSteps bound
8528
- // (metadata.stopReason "step-cap", a mutable reference the native
8529
- // loops fill by drain time). That bound is the caller's budget,
8530
- // not a provider failure, and retrying it on another provider
8531
- // spends that provider's tokens to exceed a budget the caller set.
8532
- const cappedByCallerBudget = enhancedOptions.fallbackOnMaxSteps === false &&
8533
- mcpStreamOutcome.metadata?.stopReason === "step-cap";
8534
- if (realOutputChunks === 0 &&
8535
- !cappedByCallerBudget &&
8536
- !metadata.fallbackAttempted &&
8537
- !enhancedOptions.disableInternalFallback &&
8538
- currentToolCalls().length === 0 &&
8539
- currentToolResults().length === 0) {
8540
- const fallbackStream = self.handleStreamFallback(metadata, streamState, originalPrompt, enhancedOptions, providerName, (content) => {
8541
- accumulatedContent += content;
8542
- });
8543
- const { stream: incrementalFallback } = await self.createIncrementalTTSStream({
8544
- stream: fallbackStream,
8545
- ttsOptions: enhancedOptions.tts,
8546
- providerName,
8547
- fallbackProvider: enhancedOptions.provider,
8548
- ttsMetadata: streamTtsMetadata,
8549
- onComplete: (result) => {
8550
- streamedTTSResult = result;
8551
- },
8552
- });
8553
- yield* incrementalFallback;
8554
- }
8555
- ttsResolver?.(streamedTTSResult);
8556
- // `streamState.finishReason` starts as a "stop" placeholder, before a
8557
- // single chunk exists. A provider that records how the turn really
8558
- // ended in metadata (a token-limit cut, a content filter, a step cap
8559
- // that left tool calls pending) has that adopted here, once the drain
8560
- // is done. Only the three unified values count: a raw vendor string
8561
- // or "stop" leaves the graded reason alone, and a fallback's own
8562
- // value is never overwritten.
8563
- const drainedFinishReason = mcpStreamOutcome.metadata?.finishReason;
8564
- if (!metadata.fallbackAttempted &&
8565
- (drainedFinishReason === "length" ||
8566
- drainedFinishReason === "tool-calls" ||
8567
- drainedFinishReason === "content-filter")) {
8568
- streamState.finishReason = drainedFinishReason;
8569
- }
8570
- resolvedUsage = mcpStreamOutcome.usage;
8571
- if (!resolvedUsage && mcpStreamOutcome.analytics) {
8572
- try {
8573
- const resolved = await Promise.resolve(mcpStreamOutcome.analytics);
8574
- if (resolved?.tokenUsage) {
8575
- resolvedUsage = resolved.tokenUsage;
8576
- }
8577
- }
8578
- catch {
8579
- // non-blocking
8580
- }
8581
- }
8582
- if (resolvedUsage) {
8583
- mcpStreamOutcome.usage = resolvedUsage;
8584
- }
8585
- self.emitter.emit("stream:complete", {
8586
- type: "stream:complete",
8587
- content: accumulatedContent,
8588
- provider: metadata.fallbackProvider ?? providerName,
8589
- model: metadata.fallbackModel ??
8590
- mcpStreamOutcome.model ??
8591
- enhancedOptions.model,
8592
- finishReason: streamState.finishReason ?? "stop",
8593
- prompt: enhancedOptions.input?.text ||
8594
- enhancedOptions.prompt,
8595
- metadata: {
8596
- chunkCount,
8597
- totalLength: accumulatedContent.length,
8598
- durationMs: Date.now() - streamStartTime,
8599
- sessionId,
8600
- usage: resolvedUsage,
8601
- finishReason: streamState.finishReason ?? "stop",
8602
- ...(metadata.fallbackAttempted && {
8603
- primaryProvider: providerName,
8604
- primaryModel: enhancedOptions.model,
8605
- fallback: true,
8606
- }),
8607
- },
8608
- timestamp: Date.now(),
8609
- });
8610
- }
8611
- catch (error) {
8612
- logger.debug("[NeuroLink.stream] Stream error occurred", {
8613
- error: error instanceof Error ? error.message : String(error),
8614
- name: error instanceof Error ? error.name : "UnknownError",
8615
- provider: providerName,
8616
- model: enhancedOptions.model,
8617
- chunkCount,
8618
- totalLength: accumulatedContent.length,
8619
- durationMs: Date.now() - streamStartTime,
8620
- sessionId,
8621
- });
8622
- streamError = error;
8623
- self.emitter.emit("stream:error", {
8624
- type: "stream:error",
8625
- content: error instanceof Error ? error.message : String(error),
8626
- provider: providerName,
8627
- model: enhancedOptions.model,
8628
- metadata: {
8629
- chunkCount,
8630
- totalLength: accumulatedContent.length,
8631
- durationMs: Date.now() - streamStartTime,
8632
- errorName: error instanceof Error ? error.name : "UnknownError",
8633
- sessionId,
8634
- },
8635
- timestamp: Date.now(),
8636
- });
8637
- throw error;
8638
- }
8639
- finally {
8640
- // Belt-and-braces: if TTS Mode 2 was requested but synthesis never
8641
- // ran (stream errored before reaching the TTS block, or Mode 2 path
8642
- // was skipped), resolve the audio promise to undefined so callers
8643
- // awaiting `streamResult.audio` never hang. Uses the explicit
8644
- // `ttsResolver` param (M11), not a side-channel cast.
8645
- // m4: a duplicate resolution is a silent no-op — Promise resolvers
8646
- // never throw, so no try/catch needed here.
8647
- ttsResolver?.(undefined);
8648
- logger.debug("[NeuroLink.stream] Stream finished, performing cleanup", {
8649
- provider: providerName,
8650
- model: enhancedOptions.model,
8651
- totalChunks: chunkCount,
8652
- totalLength: accumulatedContent.length,
8653
- durationMs: Date.now() - streamStartTime,
8654
- fallbackAttempted: metadata.fallbackAttempted,
8655
- guardrailsBlocked: metadata.guardrailsBlocked,
8656
- error: metadata.error,
8657
- });
8658
- // Curator P2-4: emit `generation:end` exactly once per stream so
8659
- // cost listeners receive the same contract as for `generate()`.
8660
- // The previous implementation only fired `stream:complete`, leaving
8661
- // any subscriber to `generation:end` with zero events.
8662
- //
8663
- // Dedup: native provider stream paths (Gemini 3 on Vertex / Google
8664
- // AI Studio) already emit `generation:end` themselves so Pipeline B
8665
- // (Langfuse) records a GENERATION observation. Skip our emit when
8666
- // they already fired — preserves their Pipeline B observation
8667
- // source and keeps the "exactly once" contract. Per-stream flag
8668
- // is concurrency-safe because it's scoped via AsyncLocalStorage.
8669
- if (!dedupContext.providerEmitted) {
8670
- try {
8671
- const finalProvider = metadata.fallbackProvider ?? providerName ?? "unknown";
8672
- const finalModel = metadata.fallbackModel ??
8673
- mcpStreamOutcome.model ??
8674
- enhancedOptions.model ??
8675
- "unknown";
8676
- const finalFinishReason = streamError
8677
- ? "error"
8678
- : (streamState.finishReason ?? "stop");
8679
- self.emitter.emit("generation:end", {
8680
- provider: finalProvider,
8681
- model: finalModel,
8682
- responseTime: Date.now() - streamStartTime,
8683
- toolsUsed: currentToolCalls().map((t) => t.toolName),
8684
- timestamp: Date.now(),
8685
- result: {
8686
- content: accumulatedContent,
8687
- usage: resolvedUsage,
8688
- model: finalModel,
8689
- provider: finalProvider,
8690
- finishReason: finalFinishReason,
8691
- },
8692
- prompt: enhancedOptions.input?.text ||
8693
- enhancedOptions.prompt,
8694
- temperature: enhancedOptions.temperature,
8695
- maxTokens: enhancedOptions.maxTokens,
8696
- success: !streamError,
8697
- error: streamError
8698
- ? streamError instanceof Error
8699
- ? streamError.message
8700
- : String(streamError)
8701
- : undefined,
8702
- pipelineAHandled: true,
8703
- });
8704
- }
8705
- catch (emitError) {
8706
- logger.debug("[NeuroLink.stream] generation:end listener threw — ignored", {
8707
- error: emitError instanceof Error
8708
- ? emitError.message
8709
- : String(emitError),
8710
- });
8711
- }
8712
- }
8713
- self._disableToolCacheForCurrentRequest = false;
8714
- self._toolCacheKeysServedThisRequest = new Set();
8715
- self._generationTurnActive = false;
8716
- cleanupListeners();
8717
- streamSpan.setAttribute("neurolink.response_time_ms", Date.now() - spanStartTime);
8718
- streamSpan.setAttribute(ATTR.NL_OUTPUT_LENGTH, accumulatedContent.length);
8719
- const primaryFailed = !!(metadata.error || streamError);
8720
- streamSpan.setAttribute(ATTR.GEN_AI_FINISH_REASON, primaryFailed ? "error" : "stop");
8721
- if (metadata.fallbackAttempted) {
8722
- streamSpan.setAttribute("neurolink.fallback_triggered", true);
8723
- if (metadata.fallbackProvider) {
8724
- streamSpan.setAttribute("neurolink.fallback_provider", metadata.fallbackProvider);
8725
- }
8726
- }
8727
- if (primaryFailed) {
8728
- streamSpan.setStatus({
8729
- code: SpanStatusCode.ERROR,
8730
- message: metadata.error ||
8731
- (streamError instanceof Error
8732
- ? streamError.message
8733
- : String(streamError)),
8734
- });
8735
- }
8736
- else {
8737
- streamSpan.setStatus({ code: SpanStatusCode.OK });
8738
- }
8739
- streamSpan.end();
8740
- if (accumulatedContent.trim()) {
8741
- logger.info(`[NeuroLink.stream] stream() - COMPLETE SUCCESS`, {
8742
- provider: providerName,
8743
- model: enhancedOptions.model,
8744
- responseTimeMs: Date.now() - startTime,
8745
- contentLength: accumulatedContent.length,
8746
- fallback: metadata.fallbackAttempted,
8747
- });
8748
- }
8749
- await self.storeStreamConversationMemory({
8750
- enhancedOptions,
8751
- providerName,
8752
- originalPrompt,
8753
- accumulatedContent,
8754
- startTime,
8755
- eventSequence,
8756
- // Read only now, after the stream drained: the provider's
8757
- // toolsUsed is a live getter that resolves as the loop runs.
8758
- resultToolsUsed: (() => {
8759
- const fromResult = mcpStreamOutcome.toolsUsed;
8760
- if (Array.isArray(fromResult) && fromResult.length > 0) {
8761
- return fromResult;
8762
- }
8763
- const fromCalls = currentToolCalls().map((t) => t.toolName);
8764
- return fromCalls.length > 0 ? fromCalls : undefined;
8765
- })(),
8766
- });
8767
- }
8768
- })();
8424
+ const drain = {
8425
+ enhancedOptions,
8426
+ originalPrompt,
8427
+ startTime,
8428
+ streamStartTime,
8429
+ sessionId,
8430
+ streamSpan,
8431
+ spanStartTime,
8432
+ ttsResolver,
8433
+ incrementalStream,
8434
+ streamTtsMetadata,
8435
+ tts,
8436
+ mcpStreamOutcome,
8437
+ providerName,
8438
+ dedupContext,
8439
+ streamState,
8440
+ currentToolCalls,
8441
+ currentToolResults,
8442
+ metadata,
8443
+ eventSequence,
8444
+ cleanupListeners,
8445
+ };
8446
+ const processedStream = this.drainStandardStreamOnce(drain);
8769
8447
  const streamResult = await this.processStreamResult(processedStream, enhancedOptions, factoryResult);
8770
8448
  streamState.finishReason =
8771
8449
  streamState.finishReason || streamResult.finishReason;
@@ -8862,6 +8540,288 @@ Current user's request: ${currentInput}`;
8862
8540
  return this.handleStreamError(error, options, startTime, streamId, undefined, undefined);
8863
8541
  }
8864
8542
  }
8543
+ drainStandardStreamOnce(drain) {
8544
+ const stream = this.drainStandardStream(drain);
8545
+ let started = false;
8546
+ const guarded = {
8547
+ next: () => {
8548
+ started = true;
8549
+ return stream.next();
8550
+ },
8551
+ return: async (value) => {
8552
+ if (!started) {
8553
+ started = true;
8554
+ await this.settleStandardStreamDrain(drain, {
8555
+ accumulatedContent: "",
8556
+ chunkCount: 0,
8557
+ streamError: undefined,
8558
+ resolvedUsage: undefined,
8559
+ });
8560
+ }
8561
+ return stream.return(value);
8562
+ },
8563
+ throw: (error) => stream.throw(error),
8564
+ [Symbol.asyncIterator]: () => guarded,
8565
+ };
8566
+ return guarded;
8567
+ }
8568
+ async *drainStandardStream(drain) {
8569
+ const { enhancedOptions, originalPrompt, streamStartTime, sessionId, ttsResolver, incrementalStream, streamTtsMetadata, tts, mcpStreamOutcome, providerName, streamState, currentToolCalls, currentToolResults, metadata, } = drain;
8570
+ let accumulatedContent = "";
8571
+ let chunkCount = 0;
8572
+ let streamError;
8573
+ let resolvedUsage;
8574
+ let realOutputChunks = 0;
8575
+ try {
8576
+ for await (const chunk of incrementalStream) {
8577
+ chunkCount++;
8578
+ const isNoOutputSentinel = chunk !== null &&
8579
+ typeof chunk === "object" &&
8580
+ "metadata" in chunk &&
8581
+ chunk.metadata
8582
+ ?.noOutput === true;
8583
+ const hasTextContent = chunk &&
8584
+ "content" in chunk &&
8585
+ typeof chunk.content === "string" &&
8586
+ chunk.content.length > 0;
8587
+ const hasMediaPayload = chunk !== null &&
8588
+ typeof chunk === "object" &&
8589
+ "type" in chunk &&
8590
+ (chunk.type === "audio" ||
8591
+ chunk.type === "tts_audio" ||
8592
+ chunk.type === "image");
8593
+ if (!isNoOutputSentinel && (hasTextContent || hasMediaPayload)) {
8594
+ realOutputChunks++;
8595
+ }
8596
+ if (chunk && "content" in chunk && typeof chunk.content === "string") {
8597
+ accumulatedContent += chunk.content;
8598
+ this.emitter.emit("response:chunk", chunk.content);
8599
+ this.emitter.emit("stream:chunk", {
8600
+ type: "stream:chunk",
8601
+ content: chunk.content,
8602
+ metadata: {
8603
+ chunkIndex: chunkCount,
8604
+ totalLength: accumulatedContent.length,
8605
+ ...(isNoOutputSentinel && { noOutput: true }),
8606
+ },
8607
+ timestamp: Date.now(),
8608
+ });
8609
+ }
8610
+ yield chunk;
8611
+ }
8612
+ const cappedByCallerBudget = enhancedOptions.fallbackOnMaxSteps === false &&
8613
+ mcpStreamOutcome.metadata?.stopReason === "step-cap";
8614
+ if (realOutputChunks === 0 &&
8615
+ !cappedByCallerBudget &&
8616
+ !metadata.fallbackAttempted &&
8617
+ !enhancedOptions.disableInternalFallback &&
8618
+ currentToolCalls().length === 0 &&
8619
+ currentToolResults().length === 0) {
8620
+ const fallbackStream = this.handleStreamFallback(metadata, streamState, originalPrompt, enhancedOptions, providerName, (content) => {
8621
+ accumulatedContent += content;
8622
+ });
8623
+ const { stream: incrementalFallback } = await this.createIncrementalTTSStream({
8624
+ stream: fallbackStream,
8625
+ ttsOptions: enhancedOptions.tts,
8626
+ providerName,
8627
+ fallbackProvider: enhancedOptions.provider,
8628
+ ttsMetadata: streamTtsMetadata,
8629
+ onComplete: (result) => {
8630
+ tts.result = result;
8631
+ },
8632
+ });
8633
+ yield* incrementalFallback;
8634
+ }
8635
+ ttsResolver?.(tts.result);
8636
+ const drainedFinishReason = mcpStreamOutcome.metadata?.finishReason;
8637
+ if (!metadata.fallbackAttempted &&
8638
+ (drainedFinishReason === "length" ||
8639
+ drainedFinishReason === "tool-calls" ||
8640
+ drainedFinishReason === "content-filter")) {
8641
+ streamState.finishReason = drainedFinishReason;
8642
+ }
8643
+ resolvedUsage = mcpStreamOutcome.usage;
8644
+ if (!resolvedUsage && mcpStreamOutcome.analytics) {
8645
+ try {
8646
+ const resolved = await Promise.resolve(mcpStreamOutcome.analytics);
8647
+ if (resolved?.tokenUsage) {
8648
+ resolvedUsage = resolved.tokenUsage;
8649
+ }
8650
+ }
8651
+ catch {
8652
+ // non-blocking
8653
+ }
8654
+ }
8655
+ if (resolvedUsage) {
8656
+ mcpStreamOutcome.usage = resolvedUsage;
8657
+ }
8658
+ this.emitter.emit("stream:complete", {
8659
+ type: "stream:complete",
8660
+ content: accumulatedContent,
8661
+ provider: metadata.fallbackProvider ?? providerName,
8662
+ model: metadata.fallbackModel ??
8663
+ mcpStreamOutcome.model ??
8664
+ enhancedOptions.model,
8665
+ finishReason: streamState.finishReason ?? "stop",
8666
+ prompt: enhancedOptions.input?.text ||
8667
+ enhancedOptions.prompt,
8668
+ metadata: {
8669
+ chunkCount,
8670
+ totalLength: accumulatedContent.length,
8671
+ durationMs: Date.now() - streamStartTime,
8672
+ sessionId,
8673
+ usage: resolvedUsage,
8674
+ finishReason: streamState.finishReason ?? "stop",
8675
+ ...(metadata.fallbackAttempted && {
8676
+ primaryProvider: providerName,
8677
+ primaryModel: enhancedOptions.model,
8678
+ fallback: true,
8679
+ }),
8680
+ },
8681
+ timestamp: Date.now(),
8682
+ });
8683
+ }
8684
+ catch (error) {
8685
+ logger.debug("[NeuroLink.stream] Stream error occurred", {
8686
+ error: error instanceof Error ? error.message : String(error),
8687
+ name: error instanceof Error ? error.name : "UnknownError",
8688
+ provider: providerName,
8689
+ model: enhancedOptions.model,
8690
+ chunkCount,
8691
+ totalLength: accumulatedContent.length,
8692
+ durationMs: Date.now() - streamStartTime,
8693
+ sessionId,
8694
+ });
8695
+ streamError = error instanceof Error ? error : new Error(String(error));
8696
+ this.emitter.emit("stream:error", {
8697
+ type: "stream:error",
8698
+ content: error instanceof Error ? error.message : String(error),
8699
+ provider: providerName,
8700
+ model: enhancedOptions.model,
8701
+ metadata: {
8702
+ chunkCount,
8703
+ totalLength: accumulatedContent.length,
8704
+ durationMs: Date.now() - streamStartTime,
8705
+ errorName: error instanceof Error ? error.name : "UnknownError",
8706
+ sessionId,
8707
+ },
8708
+ timestamp: Date.now(),
8709
+ });
8710
+ throw error;
8711
+ }
8712
+ finally {
8713
+ await this.settleStandardStreamDrain(drain, {
8714
+ accumulatedContent,
8715
+ chunkCount,
8716
+ streamError,
8717
+ resolvedUsage,
8718
+ });
8719
+ }
8720
+ }
8721
+ async settleStandardStreamDrain(drain, outcome) {
8722
+ const { enhancedOptions, originalPrompt, startTime, streamStartTime, streamSpan, spanStartTime, ttsResolver, mcpStreamOutcome, providerName, dedupContext, streamState, currentToolCalls, metadata, eventSequence, cleanupListeners, } = drain;
8723
+ const { accumulatedContent, chunkCount, streamError, resolvedUsage } = outcome;
8724
+ ttsResolver?.(undefined);
8725
+ logger.debug("[NeuroLink.stream] Stream finished, performing cleanup", {
8726
+ provider: providerName,
8727
+ model: enhancedOptions.model,
8728
+ totalChunks: chunkCount,
8729
+ totalLength: accumulatedContent.length,
8730
+ durationMs: Date.now() - streamStartTime,
8731
+ fallbackAttempted: metadata.fallbackAttempted,
8732
+ guardrailsBlocked: metadata.guardrailsBlocked,
8733
+ error: metadata.error,
8734
+ });
8735
+ if (!dedupContext.providerEmitted) {
8736
+ try {
8737
+ const finalProvider = metadata.fallbackProvider ?? providerName ?? "unknown";
8738
+ const finalModel = metadata.fallbackModel ??
8739
+ mcpStreamOutcome.model ??
8740
+ enhancedOptions.model ??
8741
+ "unknown";
8742
+ const finalFinishReason = streamError
8743
+ ? "error"
8744
+ : (streamState.finishReason ?? "stop");
8745
+ this.emitter.emit("generation:end", {
8746
+ provider: finalProvider,
8747
+ model: finalModel,
8748
+ responseTime: Date.now() - streamStartTime,
8749
+ toolsUsed: currentToolCalls().map((t) => t.toolName),
8750
+ timestamp: Date.now(),
8751
+ result: {
8752
+ content: accumulatedContent,
8753
+ usage: resolvedUsage,
8754
+ model: finalModel,
8755
+ provider: finalProvider,
8756
+ finishReason: finalFinishReason,
8757
+ },
8758
+ prompt: enhancedOptions.input?.text ||
8759
+ enhancedOptions.prompt,
8760
+ temperature: enhancedOptions.temperature,
8761
+ maxTokens: enhancedOptions.maxTokens,
8762
+ success: !streamError,
8763
+ error: streamError?.message,
8764
+ streamed: true,
8765
+ });
8766
+ }
8767
+ catch (emitError) {
8768
+ logger.debug("[NeuroLink.stream] generation:end listener threw — ignored", {
8769
+ error: emitError instanceof Error
8770
+ ? emitError.message
8771
+ : String(emitError),
8772
+ });
8773
+ }
8774
+ }
8775
+ this._disableToolCacheForCurrentRequest = false;
8776
+ this._toolCacheKeysServedThisRequest = new Set();
8777
+ this._generationTurnActive = false;
8778
+ cleanupListeners();
8779
+ streamSpan.setAttribute("response_time_ms", Date.now() - spanStartTime);
8780
+ streamSpan.setAttribute(ATTR.NL_OUTPUT_LENGTH, accumulatedContent.length);
8781
+ const primaryFailed = !!(metadata.error || streamError);
8782
+ streamSpan.setAttribute(ATTR.GEN_AI_FINISH_REASON, primaryFailed ? "error" : "stop");
8783
+ if (metadata.fallbackAttempted) {
8784
+ streamSpan.setAttribute("fallback_triggered", true);
8785
+ if (metadata.fallbackProvider) {
8786
+ streamSpan.setAttribute("fallback_provider", metadata.fallbackProvider);
8787
+ }
8788
+ }
8789
+ if (primaryFailed) {
8790
+ streamSpan.setStatus({
8791
+ code: SpanStatusCode.ERROR,
8792
+ message: metadata.error || streamError?.message,
8793
+ });
8794
+ }
8795
+ else {
8796
+ streamSpan.setStatus({ code: SpanStatusCode.OK });
8797
+ }
8798
+ streamSpan.end();
8799
+ if (accumulatedContent.trim()) {
8800
+ logger.info(`[NeuroLink.stream] stream() - COMPLETE SUCCESS`, {
8801
+ provider: providerName,
8802
+ model: enhancedOptions.model,
8803
+ responseTimeMs: Date.now() - startTime,
8804
+ contentLength: accumulatedContent.length,
8805
+ fallback: metadata.fallbackAttempted,
8806
+ });
8807
+ }
8808
+ await this.storeStreamConversationMemory({
8809
+ enhancedOptions,
8810
+ providerName,
8811
+ originalPrompt,
8812
+ accumulatedContent,
8813
+ startTime,
8814
+ eventSequence,
8815
+ resultToolsUsed: (() => {
8816
+ const fromResult = mcpStreamOutcome.toolsUsed;
8817
+ if (Array.isArray(fromResult) && fromResult.length > 0) {
8818
+ return fromResult;
8819
+ }
8820
+ const fromCalls = currentToolCalls().map((t) => t.toolName);
8821
+ return fromCalls.length > 0 ? fromCalls : undefined;
8822
+ })(),
8823
+ });
8824
+ }
8865
8825
  /** Wrap one provider stream with incremental TTS synthesis. */
8866
8826
  async createIncrementalTTSStream(params) {
8867
8827
  const { stream, ttsOptions, providerName, fallbackProvider, onComplete } = params;
@@ -10050,7 +10010,7 @@ Current user's request: ${currentInput}`;
10050
10010
  finishReason: "stop",
10051
10011
  },
10052
10012
  success: true,
10053
- pipelineAHandled: true,
10013
+ streamed: true,
10054
10014
  });
10055
10015
  }
10056
10016
  catch {
@@ -11097,21 +11057,16 @@ Current user's request: ${currentInput}`;
11097
11057
  return this.mcpToolBatcher.execute(toolName, params);
11098
11058
  }
11099
11059
  const executionContext = this.createToolExecutionContext(toolName, params, options);
11100
- return tracers.mcp.startActiveSpan("neurolink.tool.execute", {
11060
+ return tracers.mcp.startActiveSpan(toolSpanName(toolName), {
11101
11061
  attributes: {
11102
11062
  "tool.name": toolName,
11103
11063
  "tool.type": executionContext.toolType,
11104
11064
  "tool.input_size": executionContext.inputSize,
11105
- "tool.input_preview": executionContext.truncatedInput,
11106
- // NOT marked langfuse.internal: this is the public entrypoint for
11107
- // `NeuroLink.executeTool()`. Direct API callers (not going through
11108
- // the AI SDK) would otherwise produce zero Langfuse observations —
11109
- // the lower-level registry/discovery spans are internal wrappers.
11110
- // AI-SDK-initiated custom tools will produce both ai.toolCall and
11111
- // this span, which is the accepted tradeoff for keeping direct
11112
- // invocations observable.
11113
11065
  },
11114
- }, (toolSpan) => this.executeToolWithSpan(toolName, params, options, executionContext, toolSpan));
11066
+ }, (toolSpan) => {
11067
+ observe(toolSpan, "tool", { input: params });
11068
+ return this.executeToolWithSpan(toolName, params, options, executionContext, toolSpan);
11069
+ });
11115
11070
  }
11116
11071
  createToolExecutionContext(toolName, params, options) {
11117
11072
  const externalTool = this.externalServerManager
@@ -11136,7 +11091,6 @@ Current user's request: ${currentInput}`;
11136
11091
  externalTool,
11137
11092
  toolType,
11138
11093
  inputSize: inputStr.length,
11139
- truncatedInput: inputStr.length > 2048 ? inputStr.substring(0, 2048) : inputStr,
11140
11094
  options,
11141
11095
  hitlState: { triggered: false },
11142
11096
  };
@@ -11144,11 +11098,23 @@ Current user's request: ${currentInput}`;
11144
11098
  async executeToolWithSpan(toolName, params, options, executionContext, toolSpan) {
11145
11099
  try {
11146
11100
  const prepared = await this.prepareToolExecutionState(toolName, params, options, executionContext);
11147
- return await this.runPreparedToolExecution(toolName, params, prepared, executionContext, toolSpan);
11101
+ const result = await this.runPreparedToolExecution(toolName, params, prepared, executionContext, toolSpan);
11102
+ const failed = isNonNullObject(result) &&
11103
+ "isError" in result &&
11104
+ result.isError === true;
11105
+ observe(toolSpan, "tool", {
11106
+ output: result,
11107
+ ...(failed && {
11108
+ level: "ERROR",
11109
+ statusMessage: extractMcpErrorText(result),
11110
+ }),
11111
+ });
11112
+ return result;
11148
11113
  }
11149
11114
  catch (outerError) {
11115
+ const errMsg = outerError instanceof Error ? outerError.message : String(outerError);
11116
+ observe(toolSpan, "tool", { level: "ERROR", statusMessage: errMsg });
11150
11117
  if (!(outerError instanceof NeuroLinkError)) {
11151
- const errMsg = outerError instanceof Error ? outerError.message : String(outerError);
11152
11118
  toolSpan.recordException(outerError instanceof Error ? outerError : new Error(errMsg));
11153
11119
  toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
11154
11120
  }
@@ -13345,7 +13311,7 @@ Current user's request: ${currentInput}`;
13345
13311
  // (widely consumed as-is, e.g. by createExternalMCPTool's execute()),
13346
13312
  // so wrapping it to carry resolution metadata would be a breaking
13347
13313
  // change for every existing direct caller.
13348
- tracers.mcp.startActiveSpan("neurolink.mcp.toolNameRepair", {
13314
+ tracers.mcp.startActiveSpan("mcp.toolNameRepair", {
13349
13315
  attributes: {
13350
13316
  "mcp.server_id": serverId,
13351
13317
  "mcp.tool_name.requested": requestedName,
@@ -13882,7 +13848,13 @@ Current user's request: ${currentInput}`;
13882
13848
  * @throws when no decision provider is configured, or the call fails
13883
13849
  */
13884
13850
  async decide(options) {
13885
- return this.runDecide(options);
13851
+ const { sessionId, requestId } = options;
13852
+ this.holdTelemetry();
13853
+ return setLangfuseContext({
13854
+ ...options.context,
13855
+ sessionId: sessionId ?? options.context?.sessionId,
13856
+ requestId: requestId ?? options.context?.requestId,
13857
+ }, () => this.runDecide(options));
13886
13858
  }
13887
13859
  /**
13888
13860
  * The provider a decision call will go to, resolved exactly as `decide()`
@@ -14052,7 +14024,7 @@ Current user's request: ${currentInput}`;
14052
14024
  // request path of a tenant that has no decision model to reach, and an
14053
14025
  // event for a call that could never be made would only mislead.
14054
14026
  if (!this.resolveDecisionProviderName(merged.provider, merged.credentials)) {
14055
- return null;
14027
+ return this.tryRunDecide(merged);
14056
14028
  }
14057
14029
  // Wire id → the host's own id, so answers can be handed back under the
14058
14030
  // ids the host used, and NeuroLink's consumers never see a host id. A Map,
@@ -14230,16 +14202,20 @@ Current user's request: ${currentInput}`;
14230
14202
  // SDK config counts as much as the environment: credentials given to the
14231
14203
  // constructor or to this call can configure a provider on their own.
14232
14204
  const providerName = this.resolveDecisionProviderName(options.provider, options.credentials);
14233
- if (!providerName) {
14234
- throw new Error(`No decision provider is configured. Set ${describeDecisionProviderKeys()} (in the environment or in \`credentials\`), or pass \`provider\` explicitly.`);
14235
- }
14236
14205
  const questionIds = Object.keys(options.questions);
14237
14206
  const questionCount = questionIds.length;
14207
+ const decisionDescriptor = providerName
14208
+ ? PROVIDER_DESCRIPTORS_BY_NAME.get((PROVIDER_ALIAS_INDEX.get(providerName.toLowerCase()) ??
14209
+ providerName))
14210
+ : undefined;
14211
+ const requestedModel = options.model ??
14212
+ (decisionDescriptor && this.registeredDecisionModel(decisionDescriptor));
14238
14213
  const stamped = options;
14214
+ const hostQuestionCount = questionIds.filter(isHostDecisionKey).length;
14239
14215
  const siteSpanAttributes = stamped.site
14240
14216
  ? {
14241
14217
  "decision.site": stamped.site,
14242
- "decision.host_question_count": questionIds.filter(isHostDecisionKey).length,
14218
+ "decision.host_question_count": hostQuestionCount,
14243
14219
  }
14244
14220
  : {};
14245
14221
  // Observability parity with generate()/stream(): an OTEL span for the
@@ -14249,8 +14225,9 @@ Current user's request: ${currentInput}`;
14249
14225
  // internal consumer is fail-open, so a decision path that has silently
14250
14226
  // stopped working looks exactly like one that was never configured.
14251
14227
  return withSpan({
14252
- name: `gen_ai.${providerName}.decide`,
14228
+ name: "decide",
14253
14229
  tracer: tracers.decision,
14230
+ expectedFailure: () => !providerName,
14254
14231
  attributes: {
14255
14232
  "gen_ai.system": providerName,
14256
14233
  "gen_ai.operation.name": "decide",
@@ -14260,6 +14237,25 @@ Current user's request: ${currentInput}`;
14260
14237
  ...siteSpanAttributes,
14261
14238
  },
14262
14239
  }, async (otelSpan) => {
14240
+ observe(otelSpan, "generation", {
14241
+ model: requestedModel,
14242
+ input: { state: options.state, questions: options.questions },
14243
+ metadata: {
14244
+ provider: providerName,
14245
+ ...(stamped.site && {
14246
+ site: stamped.site,
14247
+ host_question_count: hostQuestionCount,
14248
+ }),
14249
+ },
14250
+ });
14251
+ if (!providerName) {
14252
+ const reason = `No decision provider is configured. Set ${describeDecisionProviderKeys()} (in the environment or in \`credentials\`), or pass \`provider\` explicitly.`;
14253
+ observe(otelSpan, "generation", {
14254
+ level: "WARNING",
14255
+ statusMessage: reason,
14256
+ });
14257
+ throw new Error(reason);
14258
+ }
14263
14259
  const { traceId, parentSpanId } = getActiveTraceContext();
14264
14260
  const startedAt = Date.now();
14265
14261
  let span = SpanSerializer.createSpan(SpanType.MODEL_DECISION, `decision.${providerName}`, {
@@ -14302,6 +14298,15 @@ Current user's request: ${currentInput}`;
14302
14298
  if (result.requestId) {
14303
14299
  otelSpan.setAttribute("gen_ai.response.id", result.requestId);
14304
14300
  }
14301
+ observe(otelSpan, "generation", {
14302
+ model: result.model,
14303
+ output: result.answers,
14304
+ usageDetails: usageDetails({
14305
+ input: result.usage.inputTokens,
14306
+ output: result.usage.outputTokens,
14307
+ }),
14308
+ costDetails: { total: cost },
14309
+ });
14305
14310
  span.durationMs = Date.now() - startedAt;
14306
14311
  span = SpanSerializer.updateAttributes(span, {
14307
14312
  // The key names the cost aggregator reads. `ai.model` is
@@ -14361,6 +14366,7 @@ Current user's request: ${currentInput}`;
14361
14366
  * `decisionHooks` and emits `decision:before` / `decision:after`.
14362
14367
  */
14363
14368
  async tryDecide(options) {
14369
+ this.holdTelemetry();
14364
14370
  return this.siteDecide(options);
14365
14371
  }
14366
14372
  // ==========================================================================
@@ -14977,10 +14983,8 @@ Current user's request: ${currentInput}`;
14977
14983
  *
14978
14984
  * Worker mode is the framework-provided version of the config block every
14979
14985
  * consumer used to copy by hand: conversation memory OFF, orchestration
14980
- * OFF, observability inherited from this instance with
14981
- * `autoDetectExternalProvider: true` + `skipLangfuseSpanProcessor: true`
14982
- * (worker spans join the host's tracer without duplicate Langfuse
14983
- * exports), credentials inherited, the host's tool registry shared (so
14986
+ * OFF, observability inherited from this instance (worker spans join the
14987
+ * host's tracer provider), credentials inherited, the host's tool registry shared (so
14984
14988
  * worker tool calls reuse the host's connections), and an internal log
14985
14989
  * bridge attached with a caller-supplied tag.
14986
14990
  *
@@ -15001,14 +15005,7 @@ Current user's request: ${currentInput}`;
15001
15005
  // Worker-mode fields always win over the config merge.
15002
15006
  conversationMemory: { enabled: false },
15003
15007
  enableOrchestration: false,
15004
- observability: {
15005
- ...(this.observabilityConfig ?? {}),
15006
- langfuse: {
15007
- ...(this.observabilityConfig?.langfuse ?? {}),
15008
- autoDetectExternalProvider: true,
15009
- skipLangfuseSpanProcessor: true,
15010
- },
15011
- },
15008
+ observability: this.observabilityConfig,
15012
15009
  ...(options?.shareToolRegistry !== false && {
15013
15010
  toolRegistry: this.toolRegistry,
15014
15011
  }),
@@ -15707,8 +15704,7 @@ Current user's request: ${currentInput}`;
15707
15704
  // 1. Flush and shutdown OpenTelemetry
15708
15705
  try {
15709
15706
  logger.debug("[NeuroLink] Flushing and shutting down OpenTelemetry...");
15710
- await flushOpenTelemetry();
15711
- await shutdownOpenTelemetry();
15707
+ await this.releaseTelemetryHold();
15712
15708
  logger.debug("[NeuroLink] OpenTelemetry shutdown successfully");
15713
15709
  }
15714
15710
  catch (error) {