@juspay/neurolink 12.59.0 → 13.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -3
- package/dist/auth/anthropicOAuth.js +3 -3
- package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
- package/dist/auth/sessionManager.js +4 -4
- package/dist/auth/tokenStore.js +4 -4
- package/dist/browser/neurolink.min.js +474 -473
- package/dist/cli/commands/observability.d.ts +1 -6
- package/dist/cli/commands/observability.js +0 -106
- package/dist/cli/commands/proxy.js +9 -18
- package/dist/cli/commands/telemetry.d.ts +3 -13
- package/dist/cli/commands/telemetry.js +7 -354
- package/dist/cli/loop/session.js +1 -1
- package/dist/client/httpClient.js +1 -1
- package/dist/client/sseClient.js +1 -1
- package/dist/client/streamingClient.js +2 -2
- package/dist/client/wsClient.js +1 -1
- package/dist/constants/langfuse.d.ts +25 -0
- package/dist/constants/langfuse.js +38 -0
- package/dist/constants/telemetry.d.ts +20 -0
- package/dist/constants/telemetry.js +34 -0
- package/dist/core/baseProvider.d.ts +2 -1
- package/dist/core/baseProvider.js +4 -11
- package/dist/core/conversationMemoryManager.js +5 -8
- package/dist/core/factory.js +1 -1
- package/dist/core/geminiLoopAdapter.d.ts +8 -1
- package/dist/core/geminiLoopAdapter.js +25 -1
- package/dist/core/loopEngine.js +69 -40
- package/dist/core/modules/MessageBuilder.js +2 -2
- package/dist/core/modules/StreamHandler.js +2 -2
- package/dist/core/modules/TelemetryHandler.d.ts +2 -18
- package/dist/core/modules/TelemetryHandler.js +1 -66
- package/dist/core/modules/ToolsManager.js +40 -31
- package/dist/core/nativeGenerateLoop.js +78 -8
- package/dist/core/redisConversationMemoryManager.js +8 -15
- package/dist/core/toolExecutionGuards.js +1 -13
- package/dist/index.d.ts +5 -12
- package/dist/index.js +5 -37
- package/dist/mcp/batching/requestBatcher.js +1 -1
- package/dist/mcp/externalServerManager.js +3 -21
- package/dist/mcp/toolDiscoveryService.js +4 -16
- package/dist/mcp/toolRegistry.js +2 -5
- package/dist/memory/memoryRetrievalTools.js +1 -1
- package/dist/neurolink.d.ts +16 -27
- package/dist/neurolink.js +788 -792
- package/dist/observability/exporterSpanProcessor.d.ts +50 -0
- package/dist/observability/exporterSpanProcessor.js +181 -0
- package/dist/observability/exporters/langfuseExporter.d.ts +13 -34
- package/dist/observability/exporters/langfuseExporter.js +101 -154
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/observability/langfuseMasking.d.ts +8 -0
- package/dist/observability/langfuseMasking.js +47 -0
- package/dist/observability/otlpEncoding.d.ts +20 -0
- package/dist/observability/otlpEncoding.js +85 -0
- package/dist/providers/amazonBedrock/client.js +142 -176
- package/dist/providers/amazonBedrock/loopAdapter.js +14 -1
- package/dist/providers/amazonSagemaker.js +118 -80
- package/dist/providers/anthropic/client.js +3 -52
- package/dist/providers/anthropic/loopAdapter.js +24 -1
- package/dist/providers/anthropic/rateLimitCapture.js +9 -12
- package/dist/providers/googleAiStudio/client.d.ts +4 -5
- package/dist/providers/googleAiStudio/client.js +38 -40
- package/dist/providers/googleVertex/client.d.ts +2 -7
- package/dist/providers/googleVertex/client.js +282 -378
- package/dist/providers/googleVertex/constants.js +2 -2
- package/dist/providers/litellm/client.d.ts +2 -9
- package/dist/providers/litellm/client.js +1 -63
- package/dist/providers/openAI/client.d.ts +1 -8
- package/dist/providers/openAI/client.js +0 -65
- package/dist/providers/openaiChatCompletionsBase.d.ts +1 -0
- package/dist/providers/openaiChatCompletionsBase.js +61 -4
- package/dist/providers/replicate.js +22 -4
- package/dist/proxy/codexUsage.js +7 -0
- package/dist/proxy/proxyFetch.js +19 -25
- package/dist/proxy/proxyTracer.d.ts +14 -7
- package/dist/proxy/proxyTracer.js +129 -83
- package/dist/proxy/proxyTranslationEngine.js +0 -1
- package/dist/proxy/requestLogger.d.ts +1 -1
- package/dist/proxy/requestLogger.js +39 -45
- package/dist/server/middleware/auth.d.ts +5 -1
- package/dist/server/middleware/auth.js +10 -3
- package/dist/server/middleware/common.js +1 -1
- package/dist/server/routes/agentRoutes.js +4 -4
- package/dist/server/routes/claudeProxyRoutes.js +99 -196
- package/dist/server/routes/codexProxyRoutes.js +621 -591
- package/dist/server/routes/healthRoutes.js +6 -6
- package/dist/server/routes/mcpRoutes.js +7 -7
- package/dist/server/routes/memoryRoutes.js +7 -7
- package/dist/server/routes/openApiRoutes.js +3 -3
- package/dist/server/routes/toolRoutes.js +5 -5
- package/dist/server/routes/transcribeRoutes.js +1 -1
- package/dist/server/utils/validation.d.ts +3 -3
- package/dist/services/server/ai/observability/instrumentation.d.ts +34 -201
- package/dist/services/server/ai/observability/instrumentation.js +220 -1267
- package/dist/services/server/ai/observability/langfuseSetup.d.ts +14 -0
- package/dist/services/server/ai/observability/langfuseSetup.js +55 -0
- package/dist/services/server/ai/observability/otlpSetup.d.ts +16 -0
- package/dist/services/server/ai/observability/otlpSetup.js +58 -0
- package/dist/session/globalSessionState.js +0 -11
- package/dist/telemetry/attributes.d.ts +23 -46
- package/dist/telemetry/attributes.js +76 -162
- package/dist/telemetry/chatMessages.d.ts +13 -0
- package/dist/telemetry/chatMessages.js +59 -0
- package/dist/telemetry/index.d.ts +5 -19
- package/dist/telemetry/index.js +5 -26
- package/dist/telemetry/observation.d.ts +37 -0
- package/dist/telemetry/observation.js +112 -0
- package/dist/telemetry/observationNames.d.ts +18 -0
- package/dist/telemetry/observationNames.js +24 -0
- package/dist/telemetry/spanSanitizer.d.ts +39 -0
- package/dist/telemetry/spanSanitizer.js +92 -0
- package/dist/telemetry/spanSerialization.d.ts +13 -0
- package/dist/telemetry/spanSerialization.js +46 -0
- package/dist/telemetry/tracers.d.ts +37 -18
- package/dist/telemetry/tracers.js +97 -19
- package/dist/telemetry/withSpan.d.ts +47 -25
- package/dist/telemetry/withSpan.js +158 -71
- package/dist/types/cli.d.ts +0 -11
- package/dist/types/decision.d.ts +3 -0
- package/dist/types/exporter.d.ts +54 -0
- package/dist/types/exporter.js +0 -4
- package/dist/types/generate.d.ts +4 -1
- package/dist/types/isolatedAgent.d.ts +5 -4
- package/dist/types/loopEngine.d.ts +18 -28
- package/dist/types/observability.d.ts +127 -160
- package/dist/types/providers.d.ts +15 -0
- package/dist/types/proxy.d.ts +41 -2
- package/dist/types/server.d.ts +9 -5
- package/dist/utils/anthropicTraceSanitizer.d.ts +2 -2
- package/dist/utils/anthropicTraceSanitizer.js +3 -1
- package/dist/utils/conversationMemory.js +4 -4
- package/dist/utils/fileDetector.js +1 -1
- package/dist/utils/messageBuilder.js +1 -1
- package/dist/utils/noOutputSentinel.d.ts +4 -16
- package/dist/utils/noOutputSentinel.js +12 -28
- package/dist/utils/providerRetry.js +4 -4
- package/dist/voice/livekit/brain.js +1 -1
- package/dist/voice/livekit/realtimeMemory.d.ts +1 -1
- package/dist/voice/livekit/realtimeMemory.js +66 -1
- package/dist/voice/livekit/realtimeVoiceAgent.js +1 -1
- package/docs-site/static/search-index.json +42 -64
- package/package.json +5 -4
- package/dist/telemetry/telemetryService.d.ts +0 -79
- package/dist/telemetry/telemetryService.js +0 -572
- package/dist/utils/observabilityHelpers.d.ts +0 -27
- 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 {
|
|
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 {
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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 ??
|
|
2209
|
-
traceName: langfuseIdentity.traceName ??
|
|
2286
|
+
sessionId: langfuseIdentity.sessionId ?? undefined,
|
|
2287
|
+
traceName: langfuseIdentity.traceName ?? undefined,
|
|
2210
2288
|
}, memoryWrite)
|
|
2211
|
-
:
|
|
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
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
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
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
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
|
-
*
|
|
3103
|
-
*
|
|
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
|
-
|
|
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
|
|
3120
|
+
* Get comprehensive telemetry status including Langfuse and OpenTelemetry state
|
|
3115
3121
|
*/
|
|
3116
3122
|
getTelemetryStatus() {
|
|
3117
3123
|
const langfuseConfig = this.observabilityConfig?.langfuse;
|
|
3118
|
-
const
|
|
3124
|
+
const langfuseOptions = typeof langfuseConfig === "object" ? langfuseConfig : undefined;
|
|
3125
|
+
const { hasProcessor } = getLangfuseHealthStatus();
|
|
3119
3126
|
return {
|
|
3120
|
-
enabled:
|
|
3121
|
-
langfuse: langfuseConfig
|
|
3127
|
+
enabled: isOpenTelemetryInitialized(),
|
|
3128
|
+
langfuse: langfuseConfig !== undefined || hasProcessor
|
|
3122
3129
|
? {
|
|
3123
|
-
enabled:
|
|
3124
|
-
baseUrl:
|
|
3125
|
-
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:
|
|
3136
|
+
openTelemetry: isOpenTelemetryInitialized() || process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
3129
3137
|
? {
|
|
3130
|
-
enabled:
|
|
3131
|
-
endpoint:
|
|
3132
|
-
serviceName:
|
|
3138
|
+
enabled: isOpenTelemetryInitialized(),
|
|
3139
|
+
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
3140
|
+
serviceName: process.env.OTEL_SERVICE_NAME,
|
|
3133
3141
|
}
|
|
3134
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
|
3361
|
-
*
|
|
3362
|
-
*
|
|
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
|
-
|
|
3355
|
+
const reportedCost = typeof analytics?.cost === "number" && Number.isFinite(analytics.cost)
|
|
3379
3356
|
? analytics.cost
|
|
3380
3357
|
: undefined;
|
|
3381
|
-
|
|
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 (
|
|
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, () =>
|
|
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
|
-
|
|
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
|
|
4057
|
-
return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeGenerateRequest(optionsOrPrompt, generateSpan
|
|
4025
|
+
async executeGenerateWithMetricsContext(optionsOrPrompt, generateSpan) {
|
|
4026
|
+
return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeGenerateRequest(optionsOrPrompt, generateSpan));
|
|
4058
4027
|
}
|
|
4059
|
-
async executeGenerateRequest(optionsOrPrompt, generateSpan
|
|
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("
|
|
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("
|
|
4103
|
-
generateSpan.setAttribute("
|
|
4104
|
-
generateSpan.setAttribute("
|
|
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("
|
|
4134
|
-
generateSpan.setAttribute("
|
|
4135
|
-
generateSpan.setAttribute("
|
|
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("
|
|
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("
|
|
4207
|
+
generateSpan.setAttribute("output_length", pptResult.content?.length ?? 0);
|
|
4247
4208
|
if (pptResult.analytics) {
|
|
4248
|
-
generateSpan.setAttribute("
|
|
4249
|
-
generateSpan.setAttribute("
|
|
4250
|
-
generateSpan.setAttribute("
|
|
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("
|
|
5072
|
-
generateSpan.setAttribute("
|
|
5073
|
-
generateSpan.setAttribute("
|
|
5074
|
-
generateSpan.setAttribute("
|
|
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("
|
|
5080
|
-
generateSpan.setAttribute("
|
|
5081
|
-
generateSpan.setAttribute("
|
|
5082
|
-
generateSpan.setAttribute("
|
|
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("
|
|
5204
|
-
generateSpan.setAttribute("
|
|
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("
|
|
5238
|
-
generateSpan.setAttribute("
|
|
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
|
-
|
|
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("
|
|
5553
|
-
internalSpan.setAttribute("
|
|
5554
|
-
internalSpan.setAttribute("
|
|
5555
|
-
internalSpan.setAttribute("
|
|
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("
|
|
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("
|
|
5692
|
-
internalSpan.setAttribute("
|
|
5693
|
-
internalSpan.setAttribute("
|
|
5694
|
-
internalSpan.setAttribute("
|
|
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
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
}
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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, () =>
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
|
|
8474
|
-
|
|
8475
|
-
|
|
8476
|
-
|
|
8477
|
-
|
|
8478
|
-
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
|
|
8486
|
-
|
|
8487
|
-
|
|
8488
|
-
|
|
8489
|
-
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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":
|
|
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:
|
|
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
|
|
14981
|
-
*
|
|
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
|
|
15711
|
-
await shutdownOpenTelemetry();
|
|
15707
|
+
await this.releaseTelemetryHold();
|
|
15712
15708
|
logger.debug("[NeuroLink] OpenTelemetry shutdown successfully");
|
|
15713
15709
|
}
|
|
15714
15710
|
catch (error) {
|