@juspay/neurolink 10.10.6 → 10.10.7
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 +8 -0
- package/dist/browser/neurolink.min.js +399 -399
- package/dist/constants/contextWindows.js +10 -1
- package/dist/context/anthropicLoopGuard.d.ts +1 -0
- package/dist/context/anthropicLoopGuard.js +30 -12
- package/dist/context/contextCompactor.js +19 -0
- package/dist/context/geminiLoopGuard.d.ts +54 -0
- package/dist/context/geminiLoopGuard.js +140 -0
- package/dist/core/redisConversationMemoryManager.d.ts +27 -0
- package/dist/core/redisConversationMemoryManager.js +146 -25
- package/dist/lib/constants/contextWindows.js +10 -1
- package/dist/lib/context/anthropicLoopGuard.d.ts +1 -0
- package/dist/lib/context/anthropicLoopGuard.js +30 -12
- package/dist/lib/context/contextCompactor.js +19 -0
- package/dist/lib/context/geminiLoopGuard.d.ts +54 -0
- package/dist/lib/context/geminiLoopGuard.js +141 -0
- package/dist/lib/core/redisConversationMemoryManager.d.ts +27 -0
- package/dist/lib/core/redisConversationMemoryManager.js +146 -25
- package/dist/lib/providers/googleAiStudio/client.d.ts +0 -31
- package/dist/lib/providers/googleAiStudio/client.js +118 -1
- package/dist/lib/providers/googleNativeGemini3/utils.d.ts +9 -0
- package/dist/lib/providers/googleNativeGemini3/utils.js +12 -0
- package/dist/lib/providers/googleVertex/client.d.ts +0 -45
- package/dist/lib/providers/googleVertex/client.js +201 -21
- package/dist/lib/types/context.d.ts +9 -0
- package/dist/lib/utils/redis.d.ts +60 -1
- package/dist/lib/utils/redis.js +143 -12
- package/dist/providers/googleAiStudio/client.d.ts +0 -31
- package/dist/providers/googleAiStudio/client.js +118 -1
- package/dist/providers/googleNativeGemini3/utils.d.ts +9 -0
- package/dist/providers/googleNativeGemini3/utils.js +12 -0
- package/dist/providers/googleVertex/client.d.ts +0 -45
- package/dist/providers/googleVertex/client.js +201 -21
- package/dist/types/context.d.ts +9 -0
- package/dist/utils/redis.d.ts +60 -1
- package/dist/utils/redis.js +143 -12
- package/package.json +3 -1
|
@@ -16,6 +16,8 @@ import { applyVertexAnthropicCacheBreakpoints } from "../../utils/anthropicCache
|
|
|
16
16
|
import { FileDetector } from "../../utils/fileDetector.js";
|
|
17
17
|
import { mergeMediaFileAliases, processUnifiedFilesArray, } from "../../utils/messageBuilder.js";
|
|
18
18
|
import { logger } from "../../utils/logger.js";
|
|
19
|
+
import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
|
|
20
|
+
import { ANTHROPIC_ELISION_NOTE, planAnthropicLoopReclaim, previewAnthropicToolResultText, } from "../../context/anthropicLoopGuard.js";
|
|
19
21
|
import { hasRestrictedOutputLimit, RESTRICTED_OUTPUT_TOKEN_LIMIT, toVertexAnthropicModelId, } from "../../utils/modelDetection.js";
|
|
20
22
|
import { detectImageMimeType } from "../../utils/imageDetection.js";
|
|
21
23
|
import { resolveClaudeMaxTokens } from "../../utils/tokenLimits.js";
|
|
@@ -25,7 +27,7 @@ import { createNativeThinkingConfig } from "../../utils/thinkingConfig.js";
|
|
|
25
27
|
import { TimeoutError, raceWithAbort, withTimeout, } from "../../utils/async/index.js";
|
|
26
28
|
import { parseTimeout } from "../../utils/timeout.js";
|
|
27
29
|
import { appendStepText, buildAbortedTurnMessage, buildContextCapMessage, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createContextGuard, createTextChannel, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
|
|
28
|
-
import { getContextWindowSize } from "../../constants/contextWindows.js";
|
|
30
|
+
import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
|
|
29
31
|
import { resolveLiveTool } from "../../tools/toolDiscovery.js";
|
|
30
32
|
import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
|
|
31
33
|
import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
|
|
@@ -587,6 +589,148 @@ const isAnthropicModel = (modelName) => {
|
|
|
587
589
|
* Solution: Simplify schema or reduce number of tools if this occurs.
|
|
588
590
|
* @see https://cloud.google.com/vertex-ai/docs/generative-ai/learn/models
|
|
589
591
|
*/
|
|
592
|
+
/** Byte budget above which an old tool response is previewed, not kept whole. */
|
|
593
|
+
const TOOL_RESPONSE_PREVIEW_BYTES = 2048;
|
|
594
|
+
/**
|
|
595
|
+
* Reclaim context from a Gemini-shaped loop history IN PLACE.
|
|
596
|
+
*
|
|
597
|
+
* Returns true when something was actually reclaimed, which tells the caller
|
|
598
|
+
* it is safe to continue the loop instead of stopping. Mutates `contents` so
|
|
599
|
+
* the caller's array identity (captured by the request builder) stays valid.
|
|
600
|
+
*/
|
|
601
|
+
function reclaimVertexLoopContext(contents, modelName, observedPromptTokens) {
|
|
602
|
+
const plan = planGeminiLoopReclaim({
|
|
603
|
+
contents,
|
|
604
|
+
// The usable INPUT budget, not the whole window: the window has to hold the
|
|
605
|
+
// model's output too, and the AI Studio twin already reclaims against this
|
|
606
|
+
// same definition.
|
|
607
|
+
availableInputTokens: getAvailableInputTokens("vertex", modelName),
|
|
608
|
+
provider: "vertex",
|
|
609
|
+
observedPromptTokens,
|
|
610
|
+
});
|
|
611
|
+
if (!plan) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const dropSet = new Set(plan.drop);
|
|
615
|
+
const truncateSet = new Set(plan.truncate);
|
|
616
|
+
const rebuilt = [];
|
|
617
|
+
for (let i = 0; i < contents.length; i++) {
|
|
618
|
+
if (dropSet.has(i)) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const content = contents[i];
|
|
622
|
+
if (truncateSet.has(i) && Array.isArray(content.parts)) {
|
|
623
|
+
rebuilt.push({
|
|
624
|
+
...content,
|
|
625
|
+
parts: content.parts.map((part) => {
|
|
626
|
+
if (!("functionResponse" in part)) {
|
|
627
|
+
return part;
|
|
628
|
+
}
|
|
629
|
+
const fn = part.functionResponse;
|
|
630
|
+
const text = JSON.stringify(fn.response) ?? "";
|
|
631
|
+
if (text.length <= TOOL_RESPONSE_PREVIEW_BYTES) {
|
|
632
|
+
return part;
|
|
633
|
+
}
|
|
634
|
+
// Rebuilt rather than cast: `functionResponse` requires `name`, and
|
|
635
|
+
// Critical Rule 14 forbids casting through `unknown` to paper over it.
|
|
636
|
+
return {
|
|
637
|
+
functionResponse: {
|
|
638
|
+
name: fn.name,
|
|
639
|
+
response: { result: previewGeminiToolResponseText(text) },
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}),
|
|
643
|
+
});
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
rebuilt.push(content);
|
|
647
|
+
}
|
|
648
|
+
if (dropSet.size > 0) {
|
|
649
|
+
// Gemini requires the history to start with a user turn; the note is
|
|
650
|
+
// inserted before the first surviving tool turn, never at the end where a
|
|
651
|
+
// "history was removed" cue would follow the content it refers to.
|
|
652
|
+
let noteIndex = rebuilt.findIndex((c) => Array.isArray(c.parts) &&
|
|
653
|
+
c.parts.some((part) => !!part.functionCall ||
|
|
654
|
+
!!part.functionResponse));
|
|
655
|
+
if (noteIndex < 0) {
|
|
656
|
+
noteIndex = Math.min(1, rebuilt.length);
|
|
657
|
+
}
|
|
658
|
+
rebuilt.splice(noteIndex, 0, {
|
|
659
|
+
role: "user",
|
|
660
|
+
parts: [{ text: GEMINI_ELISION_NOTE }],
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
contents.length = 0;
|
|
664
|
+
contents.push(...rebuilt);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Reclaim context from a Vertex+Claude loop history IN PLACE.
|
|
669
|
+
*
|
|
670
|
+
* Same parity upgrade as the Gemini path, but this loop carries Anthropic
|
|
671
|
+
* content blocks, so it reuses the Anthropic adapter. Returns true when
|
|
672
|
+
* something was reclaimed and the loop may continue.
|
|
673
|
+
*/
|
|
674
|
+
function reclaimVertexAnthropicContext(messages, modelName, observedPromptTokens) {
|
|
675
|
+
const plan = planAnthropicLoopReclaim({
|
|
676
|
+
conversation: messages,
|
|
677
|
+
// Usable input budget, matching the Gemini twin above.
|
|
678
|
+
availableInputTokens: getAvailableInputTokens("vertex", modelName),
|
|
679
|
+
fixedOverheadTokens: 0,
|
|
680
|
+
provider: "vertex",
|
|
681
|
+
observedPromptTokens,
|
|
682
|
+
// This loop plans only when its context guard trips, so the count it hands
|
|
683
|
+
// over is the guard's projection for the request about to be sent, not a
|
|
684
|
+
// previous request's total. Without saying so the planner has no
|
|
685
|
+
// denominator, calibration stays pinned at 1, and the reclaim is inert:
|
|
686
|
+
// the guard fires on real tokens at the same ratio the planner tests its
|
|
687
|
+
// smaller char estimate against, so the plan never fires and the turn stops
|
|
688
|
+
// instead of continuing.
|
|
689
|
+
observedDescribesCurrentPayload: true,
|
|
690
|
+
});
|
|
691
|
+
if (!plan) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
const dropSet = new Set(plan.drop);
|
|
695
|
+
const truncateSet = new Set(plan.truncate);
|
|
696
|
+
const rebuilt = [];
|
|
697
|
+
for (let i = 0; i < messages.length; i++) {
|
|
698
|
+
if (dropSet.has(i)) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const message = messages[i];
|
|
702
|
+
if (truncateSet.has(i) && Array.isArray(message.content)) {
|
|
703
|
+
rebuilt.push({
|
|
704
|
+
...message,
|
|
705
|
+
content: message.content.map((block) => {
|
|
706
|
+
if (block.type !== "tool_result") {
|
|
707
|
+
return block;
|
|
708
|
+
}
|
|
709
|
+
const text = typeof block.content === "string"
|
|
710
|
+
? block.content
|
|
711
|
+
: (JSON.stringify(block.content) ?? "");
|
|
712
|
+
return { ...block, content: previewAnthropicToolResultText(text) };
|
|
713
|
+
}),
|
|
714
|
+
});
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
rebuilt.push(message);
|
|
718
|
+
}
|
|
719
|
+
if (dropSet.size > 0) {
|
|
720
|
+
let noteIndex = rebuilt.findIndex((m) => Array.isArray(m.content) &&
|
|
721
|
+
m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
|
|
722
|
+
if (noteIndex < 0) {
|
|
723
|
+
noteIndex = Math.min(1, rebuilt.length);
|
|
724
|
+
}
|
|
725
|
+
rebuilt.splice(noteIndex, 0, {
|
|
726
|
+
role: "user",
|
|
727
|
+
content: [{ type: "text", text: ANTHROPIC_ELISION_NOTE }],
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
messages.length = 0;
|
|
731
|
+
messages.push(...rebuilt);
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
590
734
|
export class GoogleVertexProvider extends BaseProvider {
|
|
591
735
|
projectId;
|
|
592
736
|
location;
|
|
@@ -1574,11 +1718,22 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1574
1718
|
// conversation crosses the window threshold — synthesize from what
|
|
1575
1719
|
// we have instead of stepping into a provider rejection.
|
|
1576
1720
|
if (contextGuard.shouldStop()) {
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1721
|
+
// Parity upgrade: try to RECLAIM budget and keep going before
|
|
1722
|
+
// falling back to the historic stop-only behaviour. Ending the turn
|
|
1723
|
+
// early is safe but throws away work the model was mid-way through;
|
|
1724
|
+
// dropping the oldest complete tool exchanges usually buys enough
|
|
1725
|
+
// room to finish. Only when reclaiming changes nothing do we stop.
|
|
1726
|
+
const reclaimed = reclaimVertexLoopContext(currentContents, modelName, contextGuard.projectedNextPromptTokens);
|
|
1727
|
+
if (reclaimed) {
|
|
1728
|
+
contextGuard.resetAfterReclaim();
|
|
1729
|
+
}
|
|
1730
|
+
else {
|
|
1731
|
+
hitContextLimit = true;
|
|
1732
|
+
logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
|
|
1733
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
1734
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
1735
|
+
break;
|
|
1736
|
+
}
|
|
1582
1737
|
}
|
|
1583
1738
|
step++;
|
|
1584
1739
|
turnClock.noteProgress();
|
|
@@ -2550,11 +2705,22 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2550
2705
|
// conversation crosses the window threshold — synthesize from what
|
|
2551
2706
|
// we have instead of stepping into a provider rejection.
|
|
2552
2707
|
if (contextGuard.shouldStop()) {
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2708
|
+
// Parity upgrade: try to RECLAIM budget and keep going before
|
|
2709
|
+
// falling back to the historic stop-only behaviour. Ending the turn
|
|
2710
|
+
// early is safe but throws away work the model was mid-way through;
|
|
2711
|
+
// dropping the oldest complete tool exchanges usually buys enough
|
|
2712
|
+
// room to finish. Only when reclaiming changes nothing do we stop.
|
|
2713
|
+
const reclaimed = reclaimVertexLoopContext(currentContents, modelName, contextGuard.projectedNextPromptTokens);
|
|
2714
|
+
if (reclaimed) {
|
|
2715
|
+
contextGuard.resetAfterReclaim();
|
|
2716
|
+
}
|
|
2717
|
+
else {
|
|
2718
|
+
hitContextLimit = true;
|
|
2719
|
+
logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
|
|
2720
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
2721
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
2722
|
+
break;
|
|
2723
|
+
}
|
|
2558
2724
|
}
|
|
2559
2725
|
step++;
|
|
2560
2726
|
turnClock.noteProgress();
|
|
@@ -3671,11 +3837,19 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3671
3837
|
// Context guard: stop the tool loop before the accumulated
|
|
3672
3838
|
// conversation crosses the window threshold (see generate twin).
|
|
3673
3839
|
if (contextGuard.shouldStop()) {
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3840
|
+
// Parity upgrade: reclaim and continue where possible; the
|
|
3841
|
+
// historic stop-only behaviour remains the fallback.
|
|
3842
|
+
const reclaimed = reclaimVertexAnthropicContext(currentMessages, modelName, contextGuard.projectedNextPromptTokens);
|
|
3843
|
+
if (reclaimed) {
|
|
3844
|
+
contextGuard.resetAfterReclaim();
|
|
3845
|
+
}
|
|
3846
|
+
else {
|
|
3847
|
+
hitContextLimit = true;
|
|
3848
|
+
logger.warn(`[GoogleVertex] Anthropic stream turn stopped by the context guard: ` +
|
|
3849
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
3850
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
3851
|
+
break;
|
|
3852
|
+
}
|
|
3679
3853
|
}
|
|
3680
3854
|
step++;
|
|
3681
3855
|
turnClock.noteProgress();
|
|
@@ -4929,11 +5103,17 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
4929
5103
|
// this step's appended tool results/output) would cross the window
|
|
4930
5104
|
// threshold — stop the tool loop and synthesize from what we have.
|
|
4931
5105
|
if (contextGuard.shouldStop()) {
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
5106
|
+
const reclaimed = reclaimVertexAnthropicContext(currentMessages, modelName, contextGuard.projectedNextPromptTokens);
|
|
5107
|
+
if (reclaimed) {
|
|
5108
|
+
contextGuard.resetAfterReclaim();
|
|
5109
|
+
}
|
|
5110
|
+
else {
|
|
5111
|
+
hitContextLimit = true;
|
|
5112
|
+
logger.warn(`[GoogleVertex] Anthropic generate turn stopped by the context guard: ` +
|
|
5113
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
5114
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
5115
|
+
break;
|
|
5116
|
+
}
|
|
4937
5117
|
}
|
|
4938
5118
|
step++;
|
|
4939
5119
|
turnClock.noteProgress();
|
package/dist/types/context.d.ts
CHANGED
|
@@ -471,6 +471,15 @@ export type AnthropicGuardMessage = {
|
|
|
471
471
|
role: "user" | "assistant" | "system";
|
|
472
472
|
content: string | AnthropicGuardBlock[];
|
|
473
473
|
};
|
|
474
|
+
/**
|
|
475
|
+
* Structural view of one Gemini history entry, loose enough to accept both the
|
|
476
|
+
* native Vertex loop's `{ role, parts }` array and `@google/genai` contents
|
|
477
|
+
* without a cast at either call site.
|
|
478
|
+
*/
|
|
479
|
+
export type GeminiGuardContent = {
|
|
480
|
+
role: string;
|
|
481
|
+
parts: unknown[];
|
|
482
|
+
};
|
|
474
483
|
/** Tuning for {@link planLoopGuardReclaim}. */
|
|
475
484
|
export type LoopGuardPolicy = {
|
|
476
485
|
availableInputTokens: number;
|
package/dist/utils/redis.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Redis Utilities for NeuroLink
|
|
3
3
|
* Helper functions for Redis storage operations
|
|
4
4
|
*/
|
|
5
|
-
import type { RedisClient, RedisConversationObject, RedisStorageConfig } from "../types/index.js";
|
|
5
|
+
import type { ChatMessage, RedisClient, RedisConversationObject, RedisStorageConfig } from "../types/index.js";
|
|
6
6
|
/**
|
|
7
7
|
* Get a pooled Redis connection. Multiple callers with the same host:port:db
|
|
8
8
|
* share a single connection, reducing connection count.
|
|
@@ -36,6 +36,28 @@ export declare function getUserSessionsKey(config: Required<RedisStorageConfig>,
|
|
|
36
36
|
* Serializes conversation object for Redis storage
|
|
37
37
|
*/
|
|
38
38
|
export declare function serializeConversation(conversation: RedisConversationObject): string;
|
|
39
|
+
/**
|
|
40
|
+
* True for a complete `ChatMessage` — `id` included, because callers keying
|
|
41
|
+
* summary pointers and condensation groups off it are entitled to find one.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isStoredChatMessage(value: unknown): value is ChatMessage;
|
|
44
|
+
/**
|
|
45
|
+
* Coerce a stored entry into a complete `ChatMessage`, or `undefined` when its
|
|
46
|
+
* shape is unusable.
|
|
47
|
+
*
|
|
48
|
+
* Shared by BOTH read paths on purpose. Inline blobs have always been
|
|
49
|
+
* validated; once messages moved into the companion LIST the split read
|
|
50
|
+
* bypassed that check, so `null`, a number or a bare string survived
|
|
51
|
+
* `JSON.parse` and reached callers that dereference `.role`, `.content` and
|
|
52
|
+
* `.metadata`. The two storage formats must offer the same read guarantee.
|
|
53
|
+
*
|
|
54
|
+
* `id` is a separate matter: it is required on `ChatMessage`, but no read path
|
|
55
|
+
* has ever enforced it, so history written before it existed carries none.
|
|
56
|
+
* Rejecting those records would empty otherwise-healthy sessions, so a missing
|
|
57
|
+
* id is backfilled rather than fatal — prefixed, so a synthesized id is never
|
|
58
|
+
* mistaken for one an existing pointer could reference.
|
|
59
|
+
*/
|
|
60
|
+
export declare function normalizeStoredMessage(value: unknown): ChatMessage | undefined;
|
|
39
61
|
/**
|
|
40
62
|
* Deserializes conversation object from Redis storage
|
|
41
63
|
*/
|
|
@@ -58,3 +80,40 @@ export declare function scanKeys(client: RedisClient, pattern: string, batchSize
|
|
|
58
80
|
* Get normalized Redis configuration with defaults
|
|
59
81
|
*/
|
|
60
82
|
export declare function getNormalizedConfig(config: RedisStorageConfig): Required<RedisStorageConfig>;
|
|
83
|
+
/**
|
|
84
|
+
* Suffix of the companion LIST key holding a session's messages.
|
|
85
|
+
*
|
|
86
|
+
* Every `storeConversationTurn` used to re-serialize and SET the ENTIRE
|
|
87
|
+
* conversation, so per-turn write cost grew with history size. Measured
|
|
88
|
+
* against local Redis: 200 turns of 2KB messages stayed flat at 2ms, but 400
|
|
89
|
+
* turns of 20KB messages (~16MB blob) went 2ms -> 61ms, a 30x degradation on
|
|
90
|
+
* exactly the agentic tool-output profile. Splitting messages into an
|
|
91
|
+
* append-only LIST makes the per-turn write O(1) in conversation size.
|
|
92
|
+
*/
|
|
93
|
+
export declare const MESSAGES_KEY_SUFFIX = ":msgs";
|
|
94
|
+
/**
|
|
95
|
+
* Marker on a stored blob meaning "messages live in the companion LIST".
|
|
96
|
+
* A blob WITHOUT it is a legacy record whose inline `messages` array is
|
|
97
|
+
* authoritative — it is read as-is and converted on its next write, which is
|
|
98
|
+
* what makes this migration backward compatible.
|
|
99
|
+
*/
|
|
100
|
+
export declare const MESSAGES_IN_LIST_MARKER = "__nlMessagesInList";
|
|
101
|
+
/** Redis key holding a session's messages as an append-only LIST. */
|
|
102
|
+
export declare function getSessionMessagesKey(config: Required<RedisStorageConfig>, sessionId: string, userId?: string): string;
|
|
103
|
+
/**
|
|
104
|
+
* True for a companion messages LIST key.
|
|
105
|
+
*
|
|
106
|
+
* Scan-then-GET paths (`getStats`, session listing) match `${keyPrefix}*`, so
|
|
107
|
+
* they now also see these LIST keys — and `GET` on a LIST raises WRONGTYPE.
|
|
108
|
+
* They must filter with this, exactly as they already skip `:sessions` index
|
|
109
|
+
* keys.
|
|
110
|
+
*/
|
|
111
|
+
export declare function isSessionMessagesKey(key: string): boolean;
|
|
112
|
+
/** Serialize the conversation WITHOUT its messages, flagged for split reads. */
|
|
113
|
+
export declare function serializeConversationMetadata(conversation: RedisConversationObject): string;
|
|
114
|
+
/** True when a deserialized blob's messages live in the companion LIST. */
|
|
115
|
+
export declare function usesSplitMessageStorage(conversation: RedisConversationObject | null | undefined): boolean;
|
|
116
|
+
/** Parse LRANGE entries, skipping any single entry that is not usable. */
|
|
117
|
+
export declare function parseStoredMessages(entries: string[]): ChatMessage[];
|
|
118
|
+
/** Encode messages for RPUSH. */
|
|
119
|
+
export declare function encodeStoredMessages(messages: ChatMessage[]): string[];
|
package/dist/utils/redis.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Redis Utilities for NeuroLink
|
|
3
3
|
* Helper functions for Redis storage operations
|
|
4
4
|
*/
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
5
6
|
import { createClient } from "redis";
|
|
6
7
|
import { logger } from "./logger.js";
|
|
7
8
|
const SESSION_ONLY_PREFIX = "session-only:";
|
|
@@ -198,6 +199,65 @@ export function serializeConversation(conversation) {
|
|
|
198
199
|
throw error;
|
|
199
200
|
}
|
|
200
201
|
}
|
|
202
|
+
/** The exact role set `ChatMessage` allows. */
|
|
203
|
+
const STORED_MESSAGE_ROLES = new Set([
|
|
204
|
+
"user",
|
|
205
|
+
"assistant",
|
|
206
|
+
"system",
|
|
207
|
+
"tool_call",
|
|
208
|
+
"tool_result",
|
|
209
|
+
]);
|
|
210
|
+
function isStoredMessageRole(role) {
|
|
211
|
+
return typeof role === "string" && STORED_MESSAGE_ROLES.has(role);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* True for a complete `ChatMessage` — `id` included, because callers keying
|
|
215
|
+
* summary pointers and condensation groups off it are entitled to find one.
|
|
216
|
+
*/
|
|
217
|
+
export function isStoredChatMessage(value) {
|
|
218
|
+
if (typeof value !== "object" || value === null) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const candidate = value;
|
|
222
|
+
return (typeof candidate.id === "string" &&
|
|
223
|
+
typeof candidate.content === "string" &&
|
|
224
|
+
isStoredMessageRole(candidate.role));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Coerce a stored entry into a complete `ChatMessage`, or `undefined` when its
|
|
228
|
+
* shape is unusable.
|
|
229
|
+
*
|
|
230
|
+
* Shared by BOTH read paths on purpose. Inline blobs have always been
|
|
231
|
+
* validated; once messages moved into the companion LIST the split read
|
|
232
|
+
* bypassed that check, so `null`, a number or a bare string survived
|
|
233
|
+
* `JSON.parse` and reached callers that dereference `.role`, `.content` and
|
|
234
|
+
* `.metadata`. The two storage formats must offer the same read guarantee.
|
|
235
|
+
*
|
|
236
|
+
* `id` is a separate matter: it is required on `ChatMessage`, but no read path
|
|
237
|
+
* has ever enforced it, so history written before it existed carries none.
|
|
238
|
+
* Rejecting those records would empty otherwise-healthy sessions, so a missing
|
|
239
|
+
* id is backfilled rather than fatal — prefixed, so a synthesized id is never
|
|
240
|
+
* mistaken for one an existing pointer could reference.
|
|
241
|
+
*/
|
|
242
|
+
export function normalizeStoredMessage(value) {
|
|
243
|
+
if (isStoredChatMessage(value)) {
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
if (typeof value !== "object" || value === null) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const candidate = value;
|
|
250
|
+
if (typeof candidate.content !== "string" ||
|
|
251
|
+
!isStoredMessageRole(candidate.role)) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
...candidate,
|
|
256
|
+
id: `legacy-${randomUUID()}`,
|
|
257
|
+
role: candidate.role,
|
|
258
|
+
content: candidate.content,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
201
261
|
/**
|
|
202
262
|
* Deserializes conversation object from Redis storage
|
|
203
263
|
*/
|
|
@@ -235,18 +295,8 @@ export function deserializeConversation(data) {
|
|
|
235
295
|
return null;
|
|
236
296
|
}
|
|
237
297
|
// Validate each message in the messages array
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
"role" in m &&
|
|
241
|
-
"content" in m &&
|
|
242
|
-
typeof m.role === "string" &&
|
|
243
|
-
typeof m.content === "string" &&
|
|
244
|
-
(m.role === "user" ||
|
|
245
|
-
m.role === "assistant" ||
|
|
246
|
-
m.role === "system" ||
|
|
247
|
-
m.role === "tool_call" ||
|
|
248
|
-
m.role === "tool_result"));
|
|
249
|
-
if (!isValidHistory) {
|
|
298
|
+
const normalizedMessages = conversation.messages.map(normalizeStoredMessage);
|
|
299
|
+
if (normalizedMessages.some((message) => message === undefined)) {
|
|
250
300
|
logger.warn("[redisUtils] Invalid messages structure", {
|
|
251
301
|
messageCount: conversation.messages.length,
|
|
252
302
|
firstMessage: conversation.messages.length > 0
|
|
@@ -255,6 +305,7 @@ export function deserializeConversation(data) {
|
|
|
255
305
|
});
|
|
256
306
|
return null;
|
|
257
307
|
}
|
|
308
|
+
conversation.messages = normalizedMessages.filter((message) => message !== undefined);
|
|
258
309
|
logger.debug("[redisUtils] Conversation deserialized successfully", {
|
|
259
310
|
sessionId: conversation.sessionId,
|
|
260
311
|
userId: conversation.userId,
|
|
@@ -396,3 +447,83 @@ export function getNormalizedConfig(config) {
|
|
|
396
447
|
},
|
|
397
448
|
};
|
|
398
449
|
}
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Split message storage
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
/**
|
|
454
|
+
* Suffix of the companion LIST key holding a session's messages.
|
|
455
|
+
*
|
|
456
|
+
* Every `storeConversationTurn` used to re-serialize and SET the ENTIRE
|
|
457
|
+
* conversation, so per-turn write cost grew with history size. Measured
|
|
458
|
+
* against local Redis: 200 turns of 2KB messages stayed flat at 2ms, but 400
|
|
459
|
+
* turns of 20KB messages (~16MB blob) went 2ms -> 61ms, a 30x degradation on
|
|
460
|
+
* exactly the agentic tool-output profile. Splitting messages into an
|
|
461
|
+
* append-only LIST makes the per-turn write O(1) in conversation size.
|
|
462
|
+
*/
|
|
463
|
+
export const MESSAGES_KEY_SUFFIX = ":msgs";
|
|
464
|
+
/**
|
|
465
|
+
* Marker on a stored blob meaning "messages live in the companion LIST".
|
|
466
|
+
* A blob WITHOUT it is a legacy record whose inline `messages` array is
|
|
467
|
+
* authoritative — it is read as-is and converted on its next write, which is
|
|
468
|
+
* what makes this migration backward compatible.
|
|
469
|
+
*/
|
|
470
|
+
export const MESSAGES_IN_LIST_MARKER = "__nlMessagesInList";
|
|
471
|
+
/** Redis key holding a session's messages as an append-only LIST. */
|
|
472
|
+
export function getSessionMessagesKey(config, sessionId, userId) {
|
|
473
|
+
return `${getSessionKey(config, sessionId, userId)}${MESSAGES_KEY_SUFFIX}`;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* True for a companion messages LIST key.
|
|
477
|
+
*
|
|
478
|
+
* Scan-then-GET paths (`getStats`, session listing) match `${keyPrefix}*`, so
|
|
479
|
+
* they now also see these LIST keys — and `GET` on a LIST raises WRONGTYPE.
|
|
480
|
+
* They must filter with this, exactly as they already skip `:sessions` index
|
|
481
|
+
* keys.
|
|
482
|
+
*/
|
|
483
|
+
export function isSessionMessagesKey(key) {
|
|
484
|
+
return key.endsWith(MESSAGES_KEY_SUFFIX);
|
|
485
|
+
}
|
|
486
|
+
/** Serialize the conversation WITHOUT its messages, flagged for split reads. */
|
|
487
|
+
export function serializeConversationMetadata(conversation) {
|
|
488
|
+
return JSON.stringify({
|
|
489
|
+
...conversation,
|
|
490
|
+
messages: [],
|
|
491
|
+
[MESSAGES_IN_LIST_MARKER]: true,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/** True when a deserialized blob's messages live in the companion LIST. */
|
|
495
|
+
export function usesSplitMessageStorage(conversation) {
|
|
496
|
+
if (!conversation) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
const record = conversation;
|
|
500
|
+
return record[MESSAGES_IN_LIST_MARKER] === true;
|
|
501
|
+
}
|
|
502
|
+
/** Parse LRANGE entries, skipping any single entry that is not usable. */
|
|
503
|
+
export function parseStoredMessages(entries) {
|
|
504
|
+
const messages = [];
|
|
505
|
+
for (const entry of entries) {
|
|
506
|
+
try {
|
|
507
|
+
// `JSON.parse` succeeds for `null`, numbers and bare strings, so the
|
|
508
|
+
// catch below cannot filter them — the shape has to be checked.
|
|
509
|
+
const parsed = JSON.parse(entry);
|
|
510
|
+
const message = normalizeStoredMessage(parsed);
|
|
511
|
+
if (!message) {
|
|
512
|
+
logger.warn("[redisUtils] Skipping stored message with invalid shape");
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
messages.push(message);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
// One corrupt entry must not destroy a whole session's history.
|
|
519
|
+
logger.warn("[redisUtils] Skipping unparseable stored message", {
|
|
520
|
+
error: error instanceof Error ? error.message : String(error),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return messages;
|
|
525
|
+
}
|
|
526
|
+
/** Encode messages for RPUSH. */
|
|
527
|
+
export function encodeStoredMessages(messages) {
|
|
528
|
+
return messages.map((message) => JSON.stringify(message));
|
|
529
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "10.10.
|
|
3
|
+
"version": "10.10.7",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|
|
@@ -89,6 +89,8 @@
|
|
|
89
89
|
"test:openai-compat-guard": "npx tsx test/continuous-test-suite-openai-compat-guard.ts",
|
|
90
90
|
"test:anthropic-guard": "npx tsx test/continuous-test-suite-anthropic-guard.ts",
|
|
91
91
|
"test:tool-storage-parity": "npx tsx test/continuous-test-suite-tool-storage-parity.ts",
|
|
92
|
+
"test:redis-append-only": "npx tsx test/continuous-test-suite-redis-append-only.ts",
|
|
93
|
+
"test:gemini-guard": "npx tsx test/continuous-test-suite-gemini-guard.ts",
|
|
92
94
|
"test:middleware": "npx tsx test/continuous-test-suite-middleware.ts",
|
|
93
95
|
"test:observability": "npx tsx test/continuous-test-suite-observability.ts",
|
|
94
96
|
"test:ppt": "npx tsx test/continuous-test-suite-ppt.ts",
|