@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.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +271 -271
- package/dist/constants/enums.d.ts +8 -1
- package/dist/constants/enums.js +7 -0
- package/dist/lib/constants/enums.d.ts +8 -1
- package/dist/lib/constants/enums.js +7 -0
- package/dist/lib/neurolink.d.ts +6 -2
- package/dist/lib/neurolink.js +108 -36
- package/dist/lib/utils/conversationMemory.d.ts +10 -0
- package/dist/lib/utils/conversationMemory.js +185 -1
- package/dist/lib/utils/errorHandling.d.ts +13 -0
- package/dist/lib/utils/errorHandling.js +31 -0
- package/dist/neurolink.d.ts +6 -2
- package/dist/neurolink.js +108 -36
- package/dist/utils/conversationMemory.d.ts +10 -0
- package/dist/utils/conversationMemory.js +185 -1
- package/dist/utils/errorHandling.d.ts +13 -0
- package/dist/utils/errorHandling.js +31 -0
- package/package.json +2 -1
|
@@ -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",
|
package/dist/constants/enums.js
CHANGED
|
@@ -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;
|
package/dist/lib/neurolink.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/neurolink.js
CHANGED
|
@@ -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
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
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
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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") ||
|
package/dist/neurolink.d.ts
CHANGED
|
@@ -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
|