@juspay/neurolink 9.70.6 → 9.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -103,3 +103,34 @@ export declare const ATTR: {
103
103
  readonly AR_DESCRIPTION: "autoresearch.description";
104
104
  readonly AR_ERROR_CODE: "autoresearch.error_code";
105
105
  };
106
+ /**
107
+ * Langfuse observation/trace attribute names recognised by `@langfuse/otel`'s
108
+ * LangfuseSpanProcessor (already registered on the global TracerProvider). They
109
+ * let native (non-AI-SDK) provider paths emit spans that render as proper
110
+ * generation / tool observations — the same data the Vercel AI SDK's
111
+ * `experimental_telemetry` produced before providers moved to native SDKs.
112
+ */
113
+ export declare const LANGFUSE_ATTR: {
114
+ readonly TRACE_NAME: "langfuse.trace.name";
115
+ readonly TRACE_INPUT: "langfuse.trace.input";
116
+ readonly TRACE_OUTPUT: "langfuse.trace.output";
117
+ readonly OBSERVATION_TYPE: "langfuse.observation.type";
118
+ readonly OBSERVATION_INPUT: "langfuse.observation.input";
119
+ readonly OBSERVATION_OUTPUT: "langfuse.observation.output";
120
+ readonly OBSERVATION_METADATA: "langfuse.observation.metadata";
121
+ readonly OBSERVATION_MODEL_NAME: "langfuse.observation.model.name";
122
+ readonly OBSERVATION_MODEL_PARAMETERS: "langfuse.observation.model.parameters";
123
+ readonly OBSERVATION_USAGE_DETAILS: "langfuse.observation.usage_details";
124
+ readonly OBSERVATION_LEVEL: "langfuse.observation.level";
125
+ readonly OBSERVATION_STATUS_MESSAGE: "langfuse.observation.status_message";
126
+ readonly OBSERVATION_COMPLETION_START_TIME: "langfuse.observation.completion_start_time";
127
+ };
128
+ /** Default ceiling for serialized span attribute values. */
129
+ export declare const SPAN_ATTRIBUTE_MAX_CHARS = 40000;
130
+ /**
131
+ * Serialize an arbitrary value for a span attribute, hard-capped at
132
+ * `maxChars` so a pathological prompt or tool result can't put megabytes
133
+ * on a single span. Strings pass through unserialized; everything else is
134
+ * JSON-stringified with a String() fallback for circular structures.
135
+ */
136
+ export declare function spanJsonAttribute(value: unknown, maxChars?: number): string;
@@ -114,4 +114,50 @@ export const ATTR = {
114
114
  AR_DESCRIPTION: "autoresearch.description",
115
115
  AR_ERROR_CODE: "autoresearch.error_code",
116
116
  };
117
+ /**
118
+ * Langfuse observation/trace attribute names recognised by `@langfuse/otel`'s
119
+ * LangfuseSpanProcessor (already registered on the global TracerProvider). They
120
+ * let native (non-AI-SDK) provider paths emit spans that render as proper
121
+ * generation / tool observations — the same data the Vercel AI SDK's
122
+ * `experimental_telemetry` produced before providers moved to native SDKs.
123
+ */
124
+ export const LANGFUSE_ATTR = {
125
+ TRACE_NAME: "langfuse.trace.name",
126
+ TRACE_INPUT: "langfuse.trace.input",
127
+ TRACE_OUTPUT: "langfuse.trace.output",
128
+ OBSERVATION_TYPE: "langfuse.observation.type",
129
+ OBSERVATION_INPUT: "langfuse.observation.input",
130
+ OBSERVATION_OUTPUT: "langfuse.observation.output",
131
+ OBSERVATION_METADATA: "langfuse.observation.metadata",
132
+ OBSERVATION_MODEL_NAME: "langfuse.observation.model.name",
133
+ OBSERVATION_MODEL_PARAMETERS: "langfuse.observation.model.parameters",
134
+ OBSERVATION_USAGE_DETAILS: "langfuse.observation.usage_details",
135
+ OBSERVATION_LEVEL: "langfuse.observation.level",
136
+ OBSERVATION_STATUS_MESSAGE: "langfuse.observation.status_message",
137
+ OBSERVATION_COMPLETION_START_TIME: "langfuse.observation.completion_start_time",
138
+ };
139
+ /** Default ceiling for serialized span attribute values. */
140
+ export const SPAN_ATTRIBUTE_MAX_CHARS = 40_000;
141
+ /**
142
+ * Serialize an arbitrary value for a span attribute, hard-capped at
143
+ * `maxChars` so a pathological prompt or tool result can't put megabytes
144
+ * on a single span. Strings pass through unserialized; everything else is
145
+ * JSON-stringified with a String() fallback for circular structures.
146
+ */
147
+ export function spanJsonAttribute(value, maxChars = SPAN_ATTRIBUTE_MAX_CHARS) {
148
+ let serialized;
149
+ try {
150
+ serialized =
151
+ typeof value === "string"
152
+ ? value
153
+ : (JSON.stringify(value) ?? String(value));
154
+ }
155
+ catch {
156
+ serialized = String(value);
157
+ }
158
+ if (serialized.length > maxChars) {
159
+ return `${serialized.slice(0, maxChars)}...[truncated ${serialized.length - maxChars} chars]`;
160
+ }
161
+ return serialized;
162
+ }
117
163
  //# sourceMappingURL=attributes.js.map
@@ -1,7 +1,7 @@
1
1
  export { TelemetryService } from "./telemetryService.js";
2
2
  export { tracers } from "./tracers.js";
3
3
  export { withSpan, withClientSpan, withStreamSpan, withClientStreamSpan, } from "./withSpan.js";
4
- export { ATTR } from "./attributes.js";
4
+ export { ATTR, LANGFUSE_ATTR, SPAN_ATTRIBUTE_MAX_CHARS, spanJsonAttribute, } from "./attributes.js";
5
5
  /**
6
6
  * Initialize telemetry for NeuroLink
7
7
  * Reuses an existing global TracerProvider when one is already registered,
@@ -2,7 +2,7 @@
2
2
  export { TelemetryService } from "./telemetryService.js";
3
3
  export { tracers } from "./tracers.js";
4
4
  export { withSpan, withClientSpan, withStreamSpan, withClientStreamSpan, } from "./withSpan.js";
5
- export { ATTR } from "./attributes.js";
5
+ export { ATTR, LANGFUSE_ATTR, SPAN_ATTRIBUTE_MAX_CHARS, spanJsonAttribute, } from "./attributes.js";
6
6
  import { logger } from "../utils/logger.js";
7
7
  /**
8
8
  * Initialize telemetry for NeuroLink
@@ -0,0 +1,7 @@
1
+ import type { VertexAnthropicMessage } from "../types/index.js";
2
+ /**
3
+ * Strips base64 image/PDF payloads from Anthropic messages before they go on a
4
+ * trace attribute — one screenshot would otherwise be megabytes on a span.
5
+ * Other block types pass through; the serializer still applies its length cap.
6
+ */
7
+ export declare function sanitizeAnthropicMessagesForTrace(messages: VertexAnthropicMessage[]): Array<Record<string, unknown>>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Strips base64 image/PDF payloads from Anthropic messages before they go on a
3
+ * trace attribute — one screenshot would otherwise be megabytes on a span.
4
+ * Other block types pass through; the serializer still applies its length cap.
5
+ */
6
+ export function sanitizeAnthropicMessagesForTrace(messages) {
7
+ return messages.map((message) => {
8
+ if (typeof message.content === "string") {
9
+ return { role: message.role, content: message.content };
10
+ }
11
+ return {
12
+ role: message.role,
13
+ content: message.content.map((block) => {
14
+ if (block.type === "image" || block.type === "document") {
15
+ return {
16
+ type: block.type,
17
+ media_type: block.source.media_type,
18
+ base64_chars: block.source.data.length,
19
+ };
20
+ }
21
+ return block;
22
+ }),
23
+ };
24
+ });
25
+ }
26
+ //# sourceMappingURL=anthropicTraceSanitizer.js.map
@@ -49,6 +49,73 @@ function parseOrRepair(candidate) {
49
49
  return undefined;
50
50
  }
51
51
  }
52
+ /** Bounds the recursive nested-string unwrap against pathological inputs. */
53
+ const MAX_NESTED_UNWRAP_DEPTH = 6;
54
+ /**
55
+ * Recursively replace any string-valued field whose content is itself a JSON
56
+ * object/array with the parsed value. Models sometimes double-encode a NESTED
57
+ * field — e.g. `{ "attachment": "{\"k\":1}" }` instead of
58
+ * `{ "attachment": { "k": 1 } }` — which fails schema validation even though the
59
+ * intended object is right there. (`coerceJsonToSchema` already unwraps a
60
+ * stringified TOP-LEVEL object; this handles the nested case.)
61
+ *
62
+ * A parsed string is NOT re-descended into: its own string fields (e.g. an
63
+ * attachment's `content`) are the model's intended values and must be left
64
+ * alone. Recursion only walks already-structural objects/arrays to find
65
+ * stringified fields anywhere in the tree. Returns a NEW value (never mutates
66
+ * the input) plus whether anything changed, so the caller can skip a redundant
67
+ * re-validation when nothing was unwrapped. Callers MUST re-validate the result
68
+ * against the schema — that gate is what keeps an over-eager unwrap (a field
69
+ * that should stay a string) from being accepted.
70
+ */
71
+ function deepUnwrapJsonStrings(value, depth = 0) {
72
+ if (depth > MAX_NESTED_UNWRAP_DEPTH) {
73
+ return { value, changed: false };
74
+ }
75
+ if (typeof value === "string") {
76
+ const s = value.trim();
77
+ const looksJson = (s.startsWith("{") && s.endsWith("}")) ||
78
+ (s.startsWith("[") && s.endsWith("]"));
79
+ if (looksJson) {
80
+ try {
81
+ const parsed = JSON.parse(s);
82
+ if (parsed !== null && typeof parsed === "object") {
83
+ // Parsed one stringified layer. Do NOT descend into `parsed` — its
84
+ // own string fields are intended values, not double-encodings.
85
+ return { value: parsed, changed: true };
86
+ }
87
+ }
88
+ catch {
89
+ // not JSON — leave the string as-is
90
+ }
91
+ }
92
+ return { value, changed: false };
93
+ }
94
+ if (Array.isArray(value)) {
95
+ let changed = false;
96
+ const out = value.map((item) => {
97
+ const r = deepUnwrapJsonStrings(item, depth + 1);
98
+ if (r.changed) {
99
+ changed = true;
100
+ }
101
+ return r.value;
102
+ });
103
+ return { value: changed ? out : value, changed };
104
+ }
105
+ if (value !== null && typeof value === "object") {
106
+ let changed = false;
107
+ const out = {};
108
+ for (const [k, v] of Object.entries(value)) {
109
+ const r = deepUnwrapJsonStrings(v, depth + 1);
110
+ if (r.changed) {
111
+ changed = true;
112
+ }
113
+ out[k] = r.value;
114
+ }
115
+ return { value: changed ? out : value, changed };
116
+ }
117
+ return { value, changed: false };
118
+ }
52
119
  /**
53
120
  * Try to produce canonical JSON from `text`. Returns null when no JSON object
54
121
  * could be recovered (caller should then keep the raw text).
@@ -147,6 +214,24 @@ export function coerceJsonToSchema(text, schema) {
147
214
  if (safeParseable.safeParse(outcome.value).success) {
148
215
  schemaValid.push(record);
149
216
  }
217
+ else {
218
+ // The model may have double-encoded a NESTED field as a JSON string
219
+ // (e.g. `{"attachment":"{...}"}` instead of `{"attachment":{...}}`),
220
+ // which fails validation even though the intended object is present.
221
+ // Unwrap stringified object/array fields and re-validate before giving
222
+ // up — the safeParse gate rejects any over-eager unwrap.
223
+ const unwrapped = deepUnwrapJsonStrings(outcome.value);
224
+ if (unwrapped.changed &&
225
+ unwrapped.value !== null &&
226
+ typeof unwrapped.value === "object" &&
227
+ safeParseable.safeParse(unwrapped.value).success) {
228
+ schemaValid.push({
229
+ value: unwrapped.value,
230
+ repaired: true,
231
+ truncated: candidate.truncated,
232
+ });
233
+ }
234
+ }
150
235
  }
151
236
  // Among schema-valid candidates prefer the MOST COMPLETE one. With nullable
152
237
  // fields a lean object (e.g. `{summary, attachment: null}`) validates
@@ -8,3 +8,19 @@
8
8
  * must happen here and propagate to all three surfaces.
9
9
  */
10
10
  export declare function extractMcpErrorText(raw: unknown): string;
11
+ /**
12
+ * MCP tools signal failure by RETURNING `{ isError: true, ... }`, not throwing,
13
+ * so execute()'s try/catch never sees it. Returns a capped status message for
14
+ * failures (undefined for success) for the caller to set the span error level.
15
+ *
16
+ * Generic over input shape: accepts either a result object or a JSON-stringified
17
+ * envelope (different providers hand back different shapes), mirroring
18
+ * `extractMcpErrorText`. A non-JSON string has no `isError` field, so it is
19
+ * correctly treated as "not an error" (→ undefined).
20
+ *
21
+ * Layered on `extractMcpErrorText`: this adds the `isError === true` gate and
22
+ * the human-readable "MCP tool returned isError: …" prefix, while the shared
23
+ * helper owns the content parsing and the 500-char cap. When `isError` is set
24
+ * but no readable text is present, falls back to a generic message.
25
+ */
26
+ export declare function extractMcpToolErrorMessage(result: unknown): string | undefined;
@@ -33,4 +33,40 @@ export function extractMcpErrorText(raw) {
33
33
  .map((c) => c.text);
34
34
  return texts.join(" ").substring(0, 500);
35
35
  }
36
+ /**
37
+ * MCP tools signal failure by RETURNING `{ isError: true, ... }`, not throwing,
38
+ * so execute()'s try/catch never sees it. Returns a capped status message for
39
+ * failures (undefined for success) for the caller to set the span error level.
40
+ *
41
+ * Generic over input shape: accepts either a result object or a JSON-stringified
42
+ * envelope (different providers hand back different shapes), mirroring
43
+ * `extractMcpErrorText`. A non-JSON string has no `isError` field, so it is
44
+ * correctly treated as "not an error" (→ undefined).
45
+ *
46
+ * Layered on `extractMcpErrorText`: this adds the `isError === true` gate and
47
+ * the human-readable "MCP tool returned isError: …" prefix, while the shared
48
+ * helper owns the content parsing and the 500-char cap. When `isError` is set
49
+ * but no readable text is present, falls back to a generic message.
50
+ */
51
+ export function extractMcpToolErrorMessage(result) {
52
+ let resultObj = result;
53
+ if (typeof resultObj === "string") {
54
+ try {
55
+ resultObj = JSON.parse(resultObj);
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ if (!resultObj || typeof resultObj !== "object") {
62
+ return undefined;
63
+ }
64
+ if (resultObj.isError !== true) {
65
+ return undefined;
66
+ }
67
+ const text = extractMcpErrorText(resultObj);
68
+ return text
69
+ ? `MCP tool returned isError: ${text}`
70
+ : "MCP tool returned isError: true";
71
+ }
36
72
  //# sourceMappingURL=mcpErrorText.js.map
package/dist/neurolink.js CHANGED
@@ -55,7 +55,7 @@ import { createMemoryRetrievalTools } from "./memory/memoryRetrievalTools.js";
55
55
  import { getMetricsAggregator, MetricsAggregator, } from "./observability/metricsAggregator.js";
56
56
  import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, AuthenticationError, AuthorizationError, InvalidModelError, ModelAccessDeniedError, } from "./types/index.js";
57
57
  import { SpanSerializer } from "./observability/utils/spanSerializer.js";
58
- import { flushOpenTelemetry, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, } from "./services/server/ai/observability/instrumentation.js";
58
+ import { flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, stampGuestRescueIdentity, } from "./services/server/ai/observability/instrumentation.js";
59
59
  import { TaskManager } from "./tasks/taskManager.js";
60
60
  import { createTaskTools } from "./tasks/tools/taskTools.js";
61
61
  import { ATTR } from "./telemetry/attributes.js";
@@ -1378,11 +1378,8 @@ Current user's request: ${currentInput}`;
1378
1378
  * Calls add(userId, content) which internally condenses old + new via LLM.
1379
1379
  * Supports additional users with per-user prompt and maxWords overrides.
1380
1380
  */
1381
- storeMemoryInBackground(originalPrompt, responseContent, userId, additionalUsers) {
1382
- // Preserve AsyncLocalStorage context across setImmediate boundary so that
1383
- // memory writes appear under the originating Langfuse trace instead of
1384
- // becoming orphan spans.
1385
- const wrappedMemoryWrite = runWithCurrentLangfuseContext(async () => {
1381
+ storeMemoryInBackground(originalPrompt, responseContent, userId, additionalUsers, langfuseIdentity) {
1382
+ const memoryWrite = async () => {
1386
1383
  try {
1387
1384
  const client = this.ensureMemoryReady();
1388
1385
  if (!client) {
@@ -1408,7 +1405,21 @@ Current user's request: ${currentInput}`;
1408
1405
  catch (error) {
1409
1406
  logger.warn("Memory storage failed:", error);
1410
1407
  }
1411
- });
1408
+ };
1409
+ // Carry the turn's identity across the setImmediate boundary so the
1410
+ // condensation generate + redis spans don't orphan to "guest". Keep the
1411
+ // ambient store when it survived (generate path — carries conversationId,
1412
+ // metadata, …); re-establish from the caller only when it was lost (stream
1413
+ // path, which fires after the caller consumed the stream).
1414
+ const ambient = getLangfuseContext();
1415
+ const wrappedMemoryWrite = !(ambient?.traceName || ambient?.userId) &&
1416
+ (langfuseIdentity?.traceName || langfuseIdentity?.sessionId)
1417
+ ? () => setLangfuseContext({
1418
+ userId,
1419
+ sessionId: langfuseIdentity.sessionId ?? null,
1420
+ traceName: langfuseIdentity.traceName ?? null,
1421
+ }, memoryWrite)
1422
+ : runWithCurrentLangfuseContext(memoryWrite);
1412
1423
  setImmediate(wrappedMemoryWrite);
1413
1424
  }
1414
1425
  /**
@@ -2801,7 +2812,15 @@ Current user's request: ${currentInput}`;
2801
2812
  }
2802
2813
  const startedAt = Date.now();
2803
2814
  try {
2804
- return await this.runWithFallbackOrchestration(optionsOrPrompt, "generate", (opts) => tracers.sdk.startActiveSpan("neurolink.generate", { kind: SpanKind.INTERNAL }, (generateSpan) => this.executeGenerateWithMetricsContext(opts, generateSpan)));
2815
+ return await this.runWithFallbackOrchestration(optionsOrPrompt, "generate", (opts) => {
2816
+ // Capture root-ness before startActiveSpan makes generateSpan active.
2817
+ // The actual guest-rescue stamp is deferred to executeGenerateRequest,
2818
+ // AFTER prepareGenerateRequest merges auth/requestContext-derived
2819
+ // identity into options.context — otherwise an auth:{token} caller
2820
+ // with no pre-set context.userId would stamp the root span as guest.
2821
+ const generateIsRoot = !trace.getSpan(context.active());
2822
+ return tracers.sdk.startActiveSpan("neurolink.generate", { kind: SpanKind.INTERNAL }, (generateSpan) => this.executeGenerateWithMetricsContext(opts, generateSpan, generateIsRoot));
2823
+ });
2805
2824
  }
2806
2825
  catch (error) {
2807
2826
  // Lifecycle middleware (wrapGenerate.catch in builtin/lifecycle.ts)
@@ -2973,14 +2992,17 @@ Current user's request: ${currentInput}`;
2973
2992
  return { error };
2974
2993
  }
2975
2994
  }
2976
- async executeGenerateWithMetricsContext(optionsOrPrompt, generateSpan) {
2977
- return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeGenerateRequest(optionsOrPrompt, generateSpan));
2995
+ async executeGenerateWithMetricsContext(optionsOrPrompt, generateSpan, isRootSpan) {
2996
+ return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeGenerateRequest(optionsOrPrompt, generateSpan, isRootSpan));
2978
2997
  }
2979
- async executeGenerateRequest(optionsOrPrompt, generateSpan) {
2998
+ async executeGenerateRequest(optionsOrPrompt, generateSpan, isRootSpan) {
2980
2999
  let resolvedOptions;
2981
3000
  try {
2982
3001
  const { options, originalPrompt } = await this.prepareGenerateRequest(optionsOrPrompt, generateSpan);
2983
3002
  resolvedOptions = options;
3003
+ // Stamp now that prepareGenerateRequest has merged any auth/requestContext
3004
+ // identity into options.context (see capture of isRootSpan in generate()).
3005
+ stampGuestRescueIdentity(generateSpan, options.context, isRootSpan);
2984
3006
  const earlyResult = await this.maybeHandleEarlyGenerateResult(options, generateSpan);
2985
3007
  if (earlyResult) {
2986
3008
  generateSpan.setStatus({ code: SpanStatusCode.OK });
@@ -3545,7 +3567,7 @@ Current user's request: ${currentInput}`;
3545
3567
  // Memory storage
3546
3568
  if (this.shouldWriteMemory(options.memory, options.context?.userId, generateResult.content) &&
3547
3569
  options.context?.userId) {
3548
- this.storeMemoryInBackground(originalPrompt ?? "", generateResult.content.trim(), options.context.userId, options.memory?.additionalUsers);
3570
+ this.storeMemoryInBackground(originalPrompt ?? "", generateResult.content.trim(), options.context.userId, options.memory?.additionalUsers, options.context);
3549
3571
  }
3550
3572
  }
3551
3573
  /**
@@ -5531,10 +5553,20 @@ Current user's request: ${currentInput}`;
5531
5553
  [ATTR.NL_PROVIDER]: options.provider || "default",
5532
5554
  [ATTR.GEN_AI_MODEL]: options.model || "default",
5533
5555
  [ATTR.NL_INPUT_LENGTH]: options.input?.text?.length || 0,
5534
- [ATTR.NL_HAS_TOOLS]: !!(options.tools && Object.keys(options.tools).length > 0),
5556
+ // Count registered custom tools too — chat hosts put their MCP tools
5557
+ // in the registry, so options.tools alone under-reports.
5558
+ [ATTR.NL_HAS_TOOLS]: !options.disableTools &&
5559
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
5560
+ this.getCustomTools().size > 0),
5535
5561
  [ATTR.NL_STREAM_MODE]: true,
5536
5562
  },
5537
5563
  });
5564
+ // streamSpan isn't active yet, so context.active() is its parent — empty =
5565
+ // root. Capture root-ness here, but defer the actual guest-rescue stamp to
5566
+ // after validateStreamRequestOptions merges auth/requestContext identity
5567
+ // into options.context (below) — otherwise an auth:{token} caller with no
5568
+ // pre-set context.userId would stamp the root span as guest.
5569
+ const streamIsRoot = !trace.getSpan(context.active());
5538
5570
  const spanStartTime = Date.now();
5539
5571
  this._disableToolCacheForCurrentRequest = !!options.disableToolCache;
5540
5572
  try {
@@ -5576,6 +5608,8 @@ Current user's request: ${currentInput}`;
5576
5608
  const originalPrompt = options.input?.text ?? "";
5577
5609
  options.fileRegistry = this.fileRegistry;
5578
5610
  await this.validateStreamRequestOptions(options, startTime);
5611
+ // options.context now carries any auth/requestContext-derived identity.
5612
+ stampGuestRescueIdentity(streamSpan, options.context, streamIsRoot);
5579
5613
  const workflowResult = await this.maybeHandleWorkflowStreamRequest({
5580
5614
  options,
5581
5615
  startTime,
@@ -5585,6 +5619,9 @@ Current user's request: ${currentInput}`;
5585
5619
  if (workflowResult) {
5586
5620
  return workflowResult;
5587
5621
  }
5622
+ // Make neurolink.stream the active span so every provider span (generations,
5623
+ // tool calls) parents under it — one Langfuse trace per turn, not a forest.
5624
+ const streamSpanContext = trace.setSpan(context.active(), streamSpan);
5588
5625
  // TTS Mode 2 deferred: stream() emits text first, then synthesizes the
5589
5626
  // accumulated response into a single audio chunk at end-of-stream and
5590
5627
  // resolves `streamResult.audio` with the same TTSResult. The resolver is
@@ -5599,7 +5636,7 @@ Current user's request: ${currentInput}`;
5599
5636
  resolveStreamTtsAudio = resolve;
5600
5637
  })
5601
5638
  : undefined;
5602
- const streamResult = await this.setLangfuseContextFromOptions(options, () => this.runStandardStreamRequest({
5639
+ const streamResult = await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.runStandardStreamRequest({
5603
5640
  options,
5604
5641
  streamSpan,
5605
5642
  spanStartTime,
@@ -5608,7 +5645,7 @@ Current user's request: ${currentInput}`;
5608
5645
  streamId,
5609
5646
  originalPrompt,
5610
5647
  ttsResolver: resolveStreamTtsAudio,
5611
- }));
5648
+ })));
5612
5649
  if (streamSttTranscription) {
5613
5650
  streamResult.transcription = streamSttTranscription;
5614
5651
  }
@@ -6512,7 +6549,7 @@ Current user's request: ${currentInput}`;
6512
6549
  }
6513
6550
  }
6514
6551
  if (this.shouldWriteMemory(enhancedOptions.memory, enhancedOptions.context?.userId, accumulatedContent)) {
6515
- this.storeMemoryInBackground(originalPrompt ?? "", accumulatedContent.trim(), enhancedOptions.context?.userId, enhancedOptions.memory?.additionalUsers);
6552
+ this.storeMemoryInBackground(originalPrompt ?? "", accumulatedContent.trim(), enhancedOptions.context?.userId, enhancedOptions.memory?.additionalUsers, enhancedOptions.context);
6516
6553
  }
6517
6554
  }
6518
6555
  /**