@juspay/neurolink 9.57.0 → 9.57.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.
@@ -594,7 +594,14 @@ export declare enum ErrorCategory {
594
594
  PERMISSION = "permission",
595
595
  CONFIGURATION = "configuration",
596
596
  EXECUTION = "execution",
597
- SYSTEM = "system"
597
+ SYSTEM = "system",
598
+ /**
599
+ * Caller-initiated cancellation via AbortSignal. Distinct from system errors
600
+ * — represents a user/control-plane decision, not a SDK or provider failure.
601
+ * Consumers can branch on this category to differentiate "user cancelled"
602
+ * from "server error" without resorting to message-string matching.
603
+ */
604
+ ABORT = "abort"
598
605
  }
599
606
  export declare enum ErrorSeverity {
600
607
  LOW = "low",
@@ -812,6 +812,13 @@ export var ErrorCategory;
812
812
  ErrorCategory["CONFIGURATION"] = "configuration";
813
813
  ErrorCategory["EXECUTION"] = "execution";
814
814
  ErrorCategory["SYSTEM"] = "system";
815
+ /**
816
+ * Caller-initiated cancellation via AbortSignal. Distinct from system errors
817
+ * — represents a user/control-plane decision, not a SDK or provider failure.
818
+ * Consumers can branch on this category to differentiate "user cancelled"
819
+ * from "server error" without resorting to message-string matching.
820
+ */
821
+ ErrorCategory["ABORT"] = "abort";
815
822
  })(ErrorCategory || (ErrorCategory = {}));
816
823
  // Error severity levels
817
824
  export var ErrorSeverity;
@@ -594,7 +594,14 @@ export declare enum ErrorCategory {
594
594
  PERMISSION = "permission",
595
595
  CONFIGURATION = "configuration",
596
596
  EXECUTION = "execution",
597
- SYSTEM = "system"
597
+ SYSTEM = "system",
598
+ /**
599
+ * Caller-initiated cancellation via AbortSignal. Distinct from system errors
600
+ * — represents a user/control-plane decision, not a SDK or provider failure.
601
+ * Consumers can branch on this category to differentiate "user cancelled"
602
+ * from "server error" without resorting to message-string matching.
603
+ */
604
+ ABORT = "abort"
598
605
  }
599
606
  export declare enum ErrorSeverity {
600
607
  LOW = "low",
@@ -812,6 +812,13 @@ export var ErrorCategory;
812
812
  ErrorCategory["CONFIGURATION"] = "configuration";
813
813
  ErrorCategory["EXECUTION"] = "execution";
814
814
  ErrorCategory["SYSTEM"] = "system";
815
+ /**
816
+ * Caller-initiated cancellation via AbortSignal. Distinct from system errors
817
+ * — represents a user/control-plane decision, not a SDK or provider failure.
818
+ * Consumers can branch on this category to differentiate "user cancelled"
819
+ * from "server error" without resorting to message-string matching.
820
+ */
821
+ ErrorCategory["ABORT"] = "abort";
815
822
  })(ErrorCategory || (ErrorCategory = {}));
816
823
  // Error severity levels
817
824
  export var ErrorSeverity;
@@ -881,8 +881,12 @@ export declare class NeuroLink {
881
881
  * **Generation Events:**
882
882
  * - `generation:start` - Fired when text generation begins
883
883
  * - `{ provider: string, timestamp: number }`
884
- * - `generation:end` - Fired when text generation completes
885
- * - `{ provider: string, responseTime: number, toolsUsed?: string[], timestamp: number }`
884
+ * - `generation:end` - Fired when text generation completes (or fails / is aborted)
885
+ * - `{ provider: string, responseTime: number, toolsUsed?: string[], timestamp: number, success?: boolean, aborted?: boolean, error?: string }`
886
+ * - `success` is `false` for both failures and client aborts; `aborted: true`
887
+ * distinguishes the latter so consumers can route cancellations
888
+ * differently from real errors. Pipeline B's metrics span maps
889
+ * `aborted: true` events to `SpanStatus.WARNING` (not ERROR).
886
890
  *
887
891
  * **Streaming Events:**
888
892
  * - `stream:start` - Fired when streaming begins
@@ -2324,11 +2324,26 @@ Current user's request: ${currentInput}`;
2324
2324
  if (traceCtx) {
2325
2325
  span.parentSpanId = traceCtx.parentSpanId;
2326
2326
  }
2327
- // Mark failed generations with ERROR status so metrics count them correctly
2328
- const spanStatus = data.success === false || data.error
2329
- ? SpanStatus.ERROR
2330
- : SpanStatus.OK;
2331
- span = SpanSerializer.endSpan(span, spanStatus, data.error ? String(data.error) : undefined);
2327
+ // Mark failed generations with ERROR status so metrics count them
2328
+ // correctly. Client aborts (data.aborted === true) are NOT failures —
2329
+ // they are user-initiated cancellations and must not pollute the
2330
+ // failure rate. Map them to WARNING with the canonical
2331
+ // "Generation aborted by client" message (matches the Langfuse
2332
+ // ContextEnricher mapping for outer/internal generation spans).
2333
+ let spanStatus;
2334
+ let statusMessage;
2335
+ if (data.aborted === true) {
2336
+ spanStatus = SpanStatus.WARNING;
2337
+ statusMessage = "Generation aborted by client";
2338
+ }
2339
+ else if (data.success === false || data.error) {
2340
+ spanStatus = SpanStatus.ERROR;
2341
+ statusMessage = data.error ? String(data.error) : undefined;
2342
+ }
2343
+ else {
2344
+ spanStatus = SpanStatus.OK;
2345
+ }
2346
+ span = SpanSerializer.endSpan(span, spanStatus, statusMessage);
2332
2347
  span.durationMs = responseTime;
2333
2348
  // G2 fix: Check finishReason and escalate to WARNING for partial failures
2334
2349
  const finishReason = result?.finishReason ??
@@ -2674,10 +2689,22 @@ Current user's request: ${currentInput}`;
2674
2689
  return result;
2675
2690
  }
2676
2691
  catch (error) {
2677
- generateSpan.setStatus({
2678
- code: SpanStatusCode.ERROR,
2679
- message: error instanceof Error ? error.message : String(error),
2680
- });
2692
+ // Match the inner-span discrimination: client aborts are user-initiated
2693
+ // cancellations, not faults. Mark with finishReason=aborted and skip
2694
+ // ERROR status so ContextEnricher routes the outer trace to
2695
+ // langfuse.level=WARNING (matches Curator telemetry-gaps Issue 5a). All
2696
+ // other errors keep the existing ERROR status + recordException pair.
2697
+ if (isAbortError(error)) {
2698
+ generateSpan.setAttribute("ai.finishReason", "aborted");
2699
+ generateSpan.setAttribute("neurolink.aborted", true);
2700
+ }
2701
+ else {
2702
+ generateSpan.recordException(error instanceof Error ? error : new Error(String(error)));
2703
+ generateSpan.setStatus({
2704
+ code: SpanStatusCode.ERROR,
2705
+ message: error instanceof Error ? error.message : String(error),
2706
+ });
2707
+ }
2681
2708
  // G7 fix: Distinguish context overflow errors with dedicated attributes
2682
2709
  if (error instanceof ContextBudgetExceededError) {
2683
2710
  generateSpan.setAttribute("neurolink.error.type", "context_overflow");
@@ -2972,6 +2999,11 @@ Current user's request: ${currentInput}`;
2972
2999
  const errModel = typeof optionsOrPrompt === "object"
2973
3000
  ? optionsOrPrompt.model || "unknown"
2974
3001
  : "unknown";
3002
+ // Distinguish client aborts from real failures so consumers (and Langfuse)
3003
+ // can route them differently. `aborted: true` is additive — `success`
3004
+ // remains false for backwards-compat with existing listeners that only
3005
+ // branch on the boolean.
3006
+ const aborted = isAbortError(error);
2975
3007
  try {
2976
3008
  this.emitter.emit("generation:end", {
2977
3009
  provider: errProvider,
@@ -2979,6 +3011,7 @@ Current user's request: ${currentInput}`;
2979
3011
  responseTime: 0,
2980
3012
  error: error instanceof Error ? error.message : String(error),
2981
3013
  success: false,
3014
+ aborted,
2982
3015
  });
2983
3016
  }
2984
3017
  catch (emitError) {
@@ -3326,10 +3359,23 @@ Current user's request: ${currentInput}`;
3326
3359
  return await this.runGenerateTextInternalFlow(options, internalSpan, context);
3327
3360
  }
3328
3361
  catch (error) {
3329
- internalSpan.setStatus({
3330
- code: SpanStatusCode.ERROR,
3331
- message: error instanceof Error ? error.message : String(error),
3332
- });
3362
+ // Client aborts are user-initiated cancellations, not system faults.
3363
+ // Setting status=ERROR forces Langfuse to level=ERROR (see
3364
+ // ContextEnricher.onEnd instrumentation.ts:691). Instead leave status
3365
+ // unset and stamp ai.finishReason=aborted so applyNonErrorLangfuseLevel
3366
+ // maps it to level=WARNING with the canonical "Generation aborted by
3367
+ // client" status_message. Matches Curator telemetry-gaps Issue 5a.
3368
+ if (isAbortError(error)) {
3369
+ internalSpan.setAttribute("ai.finishReason", "aborted");
3370
+ internalSpan.setAttribute("neurolink.aborted", true);
3371
+ }
3372
+ else {
3373
+ internalSpan.recordException(error instanceof Error ? error : new Error(String(error)));
3374
+ internalSpan.setStatus({
3375
+ code: SpanStatusCode.ERROR,
3376
+ message: error instanceof Error ? error.message : String(error),
3377
+ });
3378
+ }
3333
3379
  throw error;
3334
3380
  }
3335
3381
  finally {
@@ -3385,6 +3431,13 @@ Current user's request: ${currentInput}`;
3385
3431
  if (recoveredResult) {
3386
3432
  return recoveredResult;
3387
3433
  }
3434
+ // Convert raw DOMException AbortErrors (and other untyped abort shapes)
3435
+ // into NeuroLinkError(ABORT) so callers can branch on
3436
+ // `error.category === ErrorCategory.ABORT` instead of message matching.
3437
+ // Skipped if the error is already a typed abort to avoid double-wrap.
3438
+ if (isAbortError(error) && !(error instanceof NeuroLinkError)) {
3439
+ throw ErrorFactory.aborted(error instanceof Error ? error : new Error(String(error)));
3440
+ }
3388
3441
  throw error;
3389
3442
  }
3390
3443
  }
@@ -3442,28 +3495,24 @@ Current user's request: ${currentInput}`;
3442
3495
  return recoveredResult;
3443
3496
  }
3444
3497
  if (isAbortError(error)) {
3445
- logger.info(`[${context.functionTag}] Generation aborted storing conversation turn for title generation`, {
3498
+ // Aborted generations DO NOT write to conversation memory.
3499
+ // Fabricating an assistant turn out of an error condition (the previous
3500
+ // "[generation was interrupted]" sentinel) pollutes the next prompt and
3501
+ // — at the right shape — causes the model to echo the sentinel as its
3502
+ // response. See Curator SI-069 / SI-071. Aborts are signalled to
3503
+ // callers via the thrown error and the "error" emitter event below;
3504
+ // there is nothing to persist, so persisting nothing is correct.
3505
+ //
3506
+ // Title generation continues to work: it reads the user message of the
3507
+ // first *successful* turn (RedisConversationMemoryManager
3508
+ // .generateConversationTitle) and never required a fabricated assistant
3509
+ // turn — the previous comment claiming otherwise was inaccurate.
3510
+ logger.info(`[${context.functionTag}] Generation aborted — skipping memory write (aborts must not pollute conversation history)`, {
3446
3511
  hasMemory: !!this.conversationMemory,
3447
3512
  memoryType: this.conversationMemory?.constructor?.name || "NONE",
3448
3513
  sessionId: options.context?.sessionId ||
3449
3514
  "unknown",
3450
3515
  });
3451
- try {
3452
- const abortedResult = {
3453
- content: "[generation was interrupted]",
3454
- provider: options.provider || "unknown",
3455
- model: options.model || "unknown",
3456
- responseTime: Date.now() - context.generateInternalStartTime,
3457
- };
3458
- await withTimeout(storeConversationTurn(this.conversationMemory, options, abortedResult, new Date(context.generateInternalStartTime), context.requestId), 5000);
3459
- }
3460
- catch (storeError) {
3461
- logger.warn(`[${context.functionTag}] Failed to store conversation turn after abort`, {
3462
- error: storeError instanceof Error
3463
- ? storeError.message
3464
- : String(storeError),
3465
- });
3466
- }
3467
3516
  }
3468
3517
  else {
3469
3518
  logger.error(`[${context.functionTag}] All generation methods failed`, {
@@ -3471,7 +3520,14 @@ Current user's request: ${currentInput}`;
3471
3520
  });
3472
3521
  }
3473
3522
  this.emitter.emit("response:end", "");
3474
- this.emitter.emit("error", error instanceof Error ? error : new Error(String(error)));
3523
+ // Node EventEmitter rethrows the original error from emit("error", e) if
3524
+ // there is no listener registered, which would short-circuit the caller's
3525
+ // catch block and prevent the abort-typed-error wrap from running. Only
3526
+ // emit when a consumer is listening; non-listening callers receive the
3527
+ // error via the thrown rejection instead, which is the canonical path.
3528
+ if (this.emitter.listenerCount("error") > 0) {
3529
+ this.emitter.emit("error", error instanceof Error ? error : new Error(String(error)));
3530
+ }
3475
3531
  return null;
3476
3532
  }
3477
3533
  async tryRecoverGenerateTextOverflow(options, functionTag, error) {
@@ -5701,8 +5757,12 @@ Current user's request: ${currentInput}`;
5701
5757
  * **Generation Events:**
5702
5758
  * - `generation:start` - Fired when text generation begins
5703
5759
  * - `{ provider: string, timestamp: number }`
5704
- * - `generation:end` - Fired when text generation completes
5705
- * - `{ provider: string, responseTime: number, toolsUsed?: string[], timestamp: number }`
5760
+ * - `generation:end` - Fired when text generation completes (or fails / is aborted)
5761
+ * - `{ provider: string, responseTime: number, toolsUsed?: string[], timestamp: number, success?: boolean, aborted?: boolean, error?: string }`
5762
+ * - `success` is `false` for both failures and client aborts; `aborted: true`
5763
+ * distinguishes the latter so consumers can route cancellations
5764
+ * differently from real errors. Pipeline B's metrics span maps
5765
+ * `aborted: true` events to `SpanStatus.WARNING` (not ERROR).
5706
5766
  *
5707
5767
  * **Streaming Events:**
5708
5768
  * - `stream:start` - Fired when streaming begins
@@ -6643,7 +6703,13 @@ Current user's request: ${currentInput}`;
6643
6703
  prepared.metrics.errorCategories[category] =
6644
6704
  (prepared.metrics.errorCategories[category] || 0) + 1;
6645
6705
  this.emitToolEndEvent(toolName, executionContext.executionStartTime, false, undefined, structuredError);
6646
- this.emitter.emit("error", structuredError);
6706
+ // Gate on listenerCount: Node EventEmitter rethrows the original error
6707
+ // from emit("error", e) when no listener is registered, which would
6708
+ // short-circuit the surrounding flow and surface as an unhandled
6709
+ // rejection. Same pattern as handleGenerateTextInternalFailure.
6710
+ if (this.emitter.listenerCount("error") > 0) {
6711
+ this.emitter.emit("error", structuredError);
6712
+ }
6647
6713
  structuredError = new NeuroLinkError({
6648
6714
  ...structuredError,
6649
6715
  context: {
@@ -6806,13 +6872,19 @@ Current user's request: ${currentInput}`;
6806
6872
  result.success === false) {
6807
6873
  const errorMessage = result.error || "Tool execution failed";
6808
6874
  const errorToEmit = new Error(errorMessage);
6809
- this.emitter.emit("error", errorToEmit);
6875
+ // Gate on listenerCount — see handleGenerateTextInternalFailure for
6876
+ // the rationale (Node EventEmitter rethrows on no listener).
6877
+ if (this.emitter.listenerCount("error") > 0) {
6878
+ this.emitter.emit("error", errorToEmit);
6879
+ }
6810
6880
  }
6811
6881
  return result;
6812
6882
  }
6813
6883
  catch (error) {
6814
6884
  const errorToEmit = error instanceof Error ? error : new Error(String(error));
6815
- this.emitter.emit("error", errorToEmit);
6885
+ if (this.emitter.listenerCount("error") > 0) {
6886
+ this.emitter.emit("error", errorToEmit);
6887
+ }
6816
6888
  // Check if tool was not found
6817
6889
  if (error instanceof Error && error.message.includes("not found")) {
6818
6890
  const availableTools = await this.getAllAvailableTools();
@@ -5,6 +5,16 @@
5
5
  import type { ConversationMemoryManager } from "../core/conversationMemoryManager.js";
6
6
  import type { RedisConversationMemoryManager } from "../core/redisConversationMemoryManager.js";
7
7
  import type { ChatMessage, ConversationMemoryConfig, SessionMemory, TextGenerationOptions, TextGenerationResult } from "../types/index.js";
8
+ /**
9
+ * Legacy sentinel string formerly written by the abort branch of
10
+ * handleGenerateTextInternalFailure (Curator SI-069 / SI-071). The producer is
11
+ * removed in this fix, but historical Redis sessions may still contain entries
12
+ * with this content. Filtered at the prompt-builder boundary so they never
13
+ * reach the provider — sessions self-heal on the next read without any
14
+ * migration. Keep in sync with any future renames; do not remove without a
15
+ * cross-repo grep.
16
+ */
17
+ export declare const ABORT_LEGACY_SENTINEL = "[generation was interrupted]";
8
18
  /**
9
19
  * Apply conversation memory defaults to user configuration
10
20
  * Merges user config with environment variables and default values
@@ -10,6 +10,85 @@ import { getAvailableInputTokens } from "../constants/contextWindows.js";
10
10
  import { buildSummarizationPrompt } from "../context/prompts/summarizationPrompt.js";
11
11
  import { logger } from "./logger.js";
12
12
  const memoryTracer = tracers.memory;
13
+ /**
14
+ * Legacy sentinel string formerly written by the abort branch of
15
+ * handleGenerateTextInternalFailure (Curator SI-069 / SI-071). The producer is
16
+ * removed in this fix, but historical Redis sessions may still contain entries
17
+ * with this content. Filtered at the prompt-builder boundary so they never
18
+ * reach the provider — sessions self-heal on the next read without any
19
+ * migration. Keep in sync with any future renames; do not remove without a
20
+ * cross-repo grep.
21
+ */
22
+ export const ABORT_LEGACY_SENTINEL = "[generation was interrupted]";
23
+ /**
24
+ * Tracks session IDs that have already emitted the
25
+ * "Dropped polluted assistant turns" warn log so we log once per session
26
+ * (not on every retrieval). The span attribute
27
+ * `neurolink.memory.polluted_turns_dropped` is still set every call, so
28
+ * Langfuse traces show the cleanup happening continuously even after the
29
+ * log is suppressed. Bounded to avoid unbounded growth on busy services —
30
+ * when capacity is reached the set is cleared (cheap) and warning resumes
31
+ * as if those sessions are new, which is acceptable behaviour.
32
+ */
33
+ const POLLUTED_WARN_DEDUP_MAX = 1024;
34
+ const pollutedWarnedSessions = new Set();
35
+ /**
36
+ * True if a stored assistant turn looks like it was carrying tool activity
37
+ * (and is therefore safe to keep even with empty text content). storeTurn
38
+ * paths historically populate one of several fields depending on which
39
+ * provider/codepath wrote it, so this checks all of them. Mirrored across
40
+ * read filter + storage guard for symmetry.
41
+ *
42
+ * - `msg.events` — stream-path event sequence (`tool:start`, `tool:end`)
43
+ * - `msg.tool` / `msg.args` — assistant turn that invoked a tool by name
44
+ * - `msg.result` — tool result attached to the assistant turn
45
+ *
46
+ * If none of these are set, the assistant turn is text-only.
47
+ *
48
+ * Named with the `message` prefix to avoid shadowing the local
49
+ * `hasToolActivity` boolean inside `storeConversationTurn` below — the two
50
+ * answer different questions (one inspects a stored message, the other
51
+ * inspects a live result object).
52
+ */
53
+ function messageHasToolActivity(msg) {
54
+ if (msg.tool || msg.args || msg.result) {
55
+ return true;
56
+ }
57
+ const events = msg.events;
58
+ if (!Array.isArray(events)) {
59
+ return false;
60
+ }
61
+ return events.some((e) => {
62
+ const type = e?.type;
63
+ return type === "tool:start" || type === "tool:end";
64
+ });
65
+ }
66
+ /**
67
+ * Decides whether an assistant turn loaded from conversation memory is safe to
68
+ * include in the prompt sent to the provider. Drops:
69
+ * - empty / whitespace-only text content with no tool activity
70
+ * - the legacy abort sentinel — but only when the turn carries no tool
71
+ * activity, mirroring the storeConversationTurn upper-layer guard so a
72
+ * hypothetical tool-call-then-aborted turn doesn't lose its tool half
73
+ * tool_call and tool_result role messages are always preserved — they
74
+ * legitimately carry empty `content` (see redisConversationMemoryManager.ts:1870
75
+ * "Can be empty for tool calls"). Filtering them would break tool-pair
76
+ * semantics that downstream `repairToolPairs` relies on.
77
+ */
78
+ function isPollutedAssistantTurn(msg) {
79
+ if (msg.role !== "assistant") {
80
+ return false;
81
+ }
82
+ const content = typeof msg.content === "string" ? msg.content : "";
83
+ const trimmed = content.trim();
84
+ if (trimmed === ABORT_LEGACY_SENTINEL) {
85
+ return !messageHasToolActivity(msg);
86
+ }
87
+ if (trimmed === "") {
88
+ return !messageHasToolActivity(msg);
89
+ }
90
+ return false;
91
+ }
13
92
  // Cached NeuroLink instance for summarization to avoid creating a new instance per call
14
93
  let cachedSummarizer = null;
15
94
  /**
@@ -66,12 +145,49 @@ export async function getConversationMessages(conversationMemory, options) {
66
145
  span.setAttribute("user.id", userId);
67
146
  }
68
147
  const enableSummarization = options.enableSummarization ?? undefined;
69
- const messages = await conversationMemory.buildContextMessages(sessionId, userId, enableSummarization);
148
+ const rawMessages = await conversationMemory.buildContextMessages(sessionId, userId, enableSummarization);
149
+ // Read-time filter: drop assistant turns that are empty/whitespace or
150
+ // carry the legacy abort sentinel before they reach the provider.
151
+ // Self-heals historical Redis sessions polluted by the now-removed
152
+ // abort-path memory write (Curator SI-069 / SI-071) and defends
153
+ // against any future "fabricate-on-error" regression. Telemetry
154
+ // attributes record how many turns were dropped so polluted sessions
155
+ // are visible in Langfuse traces.
156
+ const messages = rawMessages.filter((msg) => !isPollutedAssistantTurn(msg));
157
+ const droppedCount = rawMessages.length - messages.length;
158
+ if (droppedCount > 0) {
159
+ // Span attribute is always set so polluted sessions stay visible in
160
+ // Langfuse traces on every read — that's the persistent debugging
161
+ // signal. The warn log is deduped per session so a long-lived
162
+ // polluted conversation only generates one log line, not one per
163
+ // turn (would otherwise be noisy at scale).
164
+ span.setAttribute("neurolink.memory.polluted_turns_dropped", droppedCount);
165
+ const alreadyWarned = pollutedWarnedSessions.has(sessionId);
166
+ if (!alreadyWarned) {
167
+ if (pollutedWarnedSessions.size >= POLLUTED_WARN_DEDUP_MAX) {
168
+ pollutedWarnedSessions.clear();
169
+ }
170
+ pollutedWarnedSessions.add(sessionId);
171
+ logger.warn("[conversationMemoryUtils] Dropped polluted assistant turns from prompt context (logged once per session — span attribute records every read)", {
172
+ sessionId,
173
+ droppedCount,
174
+ remainingCount: messages.length,
175
+ });
176
+ }
177
+ else {
178
+ logger.debug("[conversationMemoryUtils] Dropped polluted assistant turns (warn already logged for this session)", {
179
+ sessionId,
180
+ droppedCount,
181
+ remainingCount: messages.length,
182
+ });
183
+ }
184
+ }
70
185
  span.setAttribute("message.count", messages.length);
71
186
  if (logger.shouldLog("debug")) {
72
187
  logger.debug("[conversationMemoryUtils] Conversation messages retrieved successfully", {
73
188
  sessionId,
74
189
  messageCount: messages.length,
190
+ droppedPollutedCount: droppedCount,
75
191
  messageTypes: messages.map((m) => m.role),
76
192
  });
77
193
  }
@@ -147,6 +263,19 @@ export async function storeConversationTurn(conversationMemory, originalOptions,
147
263
  });
148
264
  return;
149
265
  }
266
+ // Belt-and-braces guard against the abort sentinel (Curator SI-069 / SI-071).
267
+ // The abort path itself was fixed in handleGenerateTextInternalFailure to
268
+ // never call this function, but we reject the legacy sentinel here too so a
269
+ // future regression cannot re-introduce the same pollution. Tool-bearing
270
+ // turns are explicitly preserved (the model may call a tool then abort).
271
+ if (aiResponse.trim() === ABORT_LEGACY_SENTINEL && !hasToolActivity) {
272
+ logger.warn("[conversationMemoryUtils] Refusing to store legacy abort sentinel — see Curator SI-069 / SI-071", {
273
+ sessionId,
274
+ userId,
275
+ userMessageLength: userMessage.length,
276
+ });
277
+ return;
278
+ }
150
279
  let providerDetails;
151
280
  if (result.provider && result.model) {
152
281
  providerDetails = {
@@ -154,6 +283,60 @@ export async function storeConversationTurn(conversationMemory, originalOptions,
154
283
  model: result.model,
155
284
  };
156
285
  }
286
+ // Persist a minimal `events` marker only on tool-bearing assistant turns
287
+ // whose surface text would otherwise trigger the read-time filter (empty /
288
+ // whitespace-only content). Turns that already have substantive text are
289
+ // never dropped by isPollutedAssistantTurn, so attaching synthesised events
290
+ // to them would change the stored shape and token estimation for no
291
+ // benefit. Sentinel-content turns never reach this point — the upper-layer
292
+ // guard at line 340 short-circuits them.
293
+ let toolActivityEvents;
294
+ if (hasToolActivity && !aiResponse.trim()) {
295
+ const now = Date.now();
296
+ const usedNames = new Set();
297
+ if (Array.isArray(result.toolsUsed)) {
298
+ for (const t of result.toolsUsed) {
299
+ if (typeof t === "string" && t) {
300
+ usedNames.add(t);
301
+ }
302
+ }
303
+ }
304
+ if (Array.isArray(result.toolExecutions)) {
305
+ for (const exec of result.toolExecutions) {
306
+ const name = exec?.toolName;
307
+ if (typeof name === "string" && name) {
308
+ usedNames.add(name);
309
+ }
310
+ }
311
+ }
312
+ toolActivityEvents = [];
313
+ let seq = 0;
314
+ for (const name of usedNames) {
315
+ // Match the canonical ToolExecutionEvent shape (src/lib/types/tools.ts):
316
+ // `tool` is the required field, `toolName` is the documented compat
317
+ // alias. Populate both so downstream consumers reading either name
318
+ // work uniformly.
319
+ toolActivityEvents.push({
320
+ type: "tool:start",
321
+ seq: seq++,
322
+ timestamp: now,
323
+ tool: name,
324
+ toolName: name,
325
+ });
326
+ }
327
+ if (toolActivityEvents.length === 0) {
328
+ // Tool activity reported but no names extractable — still leave a
329
+ // marker so retrieval doesn't drop the turn. Both `tool` and
330
+ // `toolName` are populated for the same compat reason.
331
+ toolActivityEvents.push({
332
+ type: "tool:start",
333
+ seq: 0,
334
+ timestamp: now,
335
+ tool: "unknown",
336
+ toolName: "unknown",
337
+ });
338
+ }
339
+ }
157
340
  await memoryTracer.startActiveSpan("neurolink.conversation.storeTurn", {
158
341
  kind: SpanKind.INTERNAL,
159
342
  attributes: {
@@ -174,6 +357,7 @@ export async function storeConversationTurn(conversationMemory, originalOptions,
174
357
  providerDetails,
175
358
  enableSummarization: originalOptions.enableSummarization,
176
359
  requestId,
360
+ events: toolActivityEvents,
177
361
  tokenUsage: result.usage
178
362
  ? {
179
363
  inputTokens: result.usage.input,
@@ -17,6 +17,7 @@ export declare const ERROR_CODES: {
17
17
  readonly PROVIDER_NOT_AVAILABLE: "PROVIDER_NOT_AVAILABLE";
18
18
  readonly PROVIDER_AUTH_FAILED: "PROVIDER_AUTH_FAILED";
19
19
  readonly PROVIDER_QUOTA_EXCEEDED: "PROVIDER_QUOTA_EXCEEDED";
20
+ readonly OPERATION_ABORTED: "OPERATION_ABORTED";
20
21
  readonly INVALID_CONFIGURATION: "INVALID_CONFIGURATION";
21
22
  readonly MISSING_CONFIGURATION: "MISSING_CONFIGURATION";
22
23
  readonly INVALID_VIDEO_RESOLUTION: "INVALID_VIDEO_RESOLUTION";
@@ -106,6 +107,18 @@ export declare class ErrorFactory {
106
107
  * Create a memory exhaustion error
107
108
  */
108
109
  static memoryExhausted(toolName: string, memoryUsageMB: number): NeuroLinkError;
110
+ /**
111
+ * Create a typed abort error preserving the originating exception. Callers
112
+ * can switch on `error.category === ErrorCategory.ABORT` and
113
+ * `error.code === ERROR_CODES.OPERATION_ABORTED` instead of message-string
114
+ * matching DOMException / AI SDK error wrappers.
115
+ *
116
+ * `error.name` is intentionally set to "AbortError" (overriding the default
117
+ * "NeuroLinkError") so existing callers that branch on
118
+ * `err.name === "AbortError"` keep working without code changes — the new
119
+ * structured fields (category, code, retriable) are additive.
120
+ */
121
+ static aborted(originalError?: Error): NeuroLinkError;
109
122
  /**
110
123
  * Create a missing configuration error (e.g., missing API key)
111
124
  */
@@ -23,6 +23,8 @@ export const ERROR_CODES = {
23
23
  PROVIDER_NOT_AVAILABLE: "PROVIDER_NOT_AVAILABLE",
24
24
  PROVIDER_AUTH_FAILED: "PROVIDER_AUTH_FAILED",
25
25
  PROVIDER_QUOTA_EXCEEDED: "PROVIDER_QUOTA_EXCEEDED",
26
+ // Cancellation
27
+ OPERATION_ABORTED: "OPERATION_ABORTED",
26
28
  // Configuration errors
27
29
  INVALID_CONFIGURATION: "INVALID_CONFIGURATION",
28
30
  MISSING_CONFIGURATION: "MISSING_CONFIGURATION",
@@ -201,6 +203,30 @@ export class ErrorFactory {
201
203
  toolName,
202
204
  });
203
205
  }
206
+ /**
207
+ * Create a typed abort error preserving the originating exception. Callers
208
+ * can switch on `error.category === ErrorCategory.ABORT` and
209
+ * `error.code === ERROR_CODES.OPERATION_ABORTED` instead of message-string
210
+ * matching DOMException / AI SDK error wrappers.
211
+ *
212
+ * `error.name` is intentionally set to "AbortError" (overriding the default
213
+ * "NeuroLinkError") so existing callers that branch on
214
+ * `err.name === "AbortError"` keep working without code changes — the new
215
+ * structured fields (category, code, retriable) are additive.
216
+ */
217
+ static aborted(originalError) {
218
+ const err = new NeuroLinkError({
219
+ code: ERROR_CODES.OPERATION_ABORTED,
220
+ message: originalError?.message || "The operation was aborted",
221
+ category: ErrorCategory.ABORT,
222
+ severity: ErrorSeverity.LOW,
223
+ retriable: false,
224
+ context: {},
225
+ originalError,
226
+ });
227
+ err.name = "AbortError";
228
+ return err;
229
+ }
204
230
  // ============================================================================
205
231
  // CONFIGURATION ERRORS
206
232
  // ============================================================================
@@ -904,6 +930,11 @@ export function isAbortError(error) {
904
930
  if (error instanceof Error && error.name === "AbortError") {
905
931
  return true;
906
932
  }
933
+ // Typed NeuroLinkError abort - canonical from-now-on shape.
934
+ if (error instanceof NeuroLinkError &&
935
+ error.category === ErrorCategory.ABORT) {
936
+ return true;
937
+ }
907
938
  if (error instanceof Error &&
908
939
  (error.message?.includes("This operation was aborted") ||
909
940
  error.message?.includes("The operation was aborted") ||
@@ -881,8 +881,12 @@ export declare class NeuroLink {
881
881
  * **Generation Events:**
882
882
  * - `generation:start` - Fired when text generation begins
883
883
  * - `{ provider: string, timestamp: number }`
884
- * - `generation:end` - Fired when text generation completes
885
- * - `{ provider: string, responseTime: number, toolsUsed?: string[], timestamp: number }`
884
+ * - `generation:end` - Fired when text generation completes (or fails / is aborted)
885
+ * - `{ provider: string, responseTime: number, toolsUsed?: string[], timestamp: number, success?: boolean, aborted?: boolean, error?: string }`
886
+ * - `success` is `false` for both failures and client aborts; `aborted: true`
887
+ * distinguishes the latter so consumers can route cancellations
888
+ * differently from real errors. Pipeline B's metrics span maps
889
+ * `aborted: true` events to `SpanStatus.WARNING` (not ERROR).
886
890
  *
887
891
  * **Streaming Events:**
888
892
  * - `stream:start` - Fired when streaming begins