@juspay/neurolink 10.10.5 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/browser/neurolink.min.js +400 -400
  3. package/dist/constants/contextWindows.js +10 -1
  4. package/dist/context/anthropicLoopGuard.d.ts +1 -0
  5. package/dist/context/anthropicLoopGuard.js +30 -12
  6. package/dist/context/contextCompactor.js +19 -0
  7. package/dist/context/geminiLoopGuard.d.ts +54 -0
  8. package/dist/context/geminiLoopGuard.js +140 -0
  9. package/dist/core/conversationMemoryManager.d.ts +25 -0
  10. package/dist/core/conversationMemoryManager.js +71 -0
  11. package/dist/core/redisConversationMemoryManager.d.ts +27 -0
  12. package/dist/core/redisConversationMemoryManager.js +146 -25
  13. package/dist/lib/constants/contextWindows.js +10 -1
  14. package/dist/lib/context/anthropicLoopGuard.d.ts +1 -0
  15. package/dist/lib/context/anthropicLoopGuard.js +30 -12
  16. package/dist/lib/context/contextCompactor.js +19 -0
  17. package/dist/lib/context/geminiLoopGuard.d.ts +54 -0
  18. package/dist/lib/context/geminiLoopGuard.js +141 -0
  19. package/dist/lib/core/conversationMemoryManager.d.ts +25 -0
  20. package/dist/lib/core/conversationMemoryManager.js +71 -0
  21. package/dist/lib/core/redisConversationMemoryManager.d.ts +27 -0
  22. package/dist/lib/core/redisConversationMemoryManager.js +146 -25
  23. package/dist/lib/neurolink.d.ts +8 -2
  24. package/dist/lib/neurolink.js +18 -11
  25. package/dist/lib/providers/googleAiStudio/client.d.ts +0 -31
  26. package/dist/lib/providers/googleAiStudio/client.js +118 -1
  27. package/dist/lib/providers/googleNativeGemini3/utils.d.ts +9 -0
  28. package/dist/lib/providers/googleNativeGemini3/utils.js +12 -0
  29. package/dist/lib/providers/googleVertex/client.d.ts +0 -45
  30. package/dist/lib/providers/googleVertex/client.js +201 -21
  31. package/dist/lib/types/context.d.ts +9 -0
  32. package/dist/lib/types/conversationMemoryInterface.d.ts +22 -0
  33. package/dist/lib/utils/redis.d.ts +60 -1
  34. package/dist/lib/utils/redis.js +143 -12
  35. package/dist/neurolink.d.ts +8 -2
  36. package/dist/neurolink.js +18 -11
  37. package/dist/providers/googleAiStudio/client.d.ts +0 -31
  38. package/dist/providers/googleAiStudio/client.js +118 -1
  39. package/dist/providers/googleNativeGemini3/utils.d.ts +9 -0
  40. package/dist/providers/googleNativeGemini3/utils.js +12 -0
  41. package/dist/providers/googleVertex/client.d.ts +0 -45
  42. package/dist/providers/googleVertex/client.js +201 -21
  43. package/dist/types/context.d.ts +9 -0
  44. package/dist/types/conversationMemoryInterface.d.ts +22 -0
  45. package/dist/utils/redis.d.ts +60 -1
  46. package/dist/utils/redis.js +143 -12
  47. package/package.json +4 -1
@@ -392,6 +392,77 @@ export class ConversationMemoryManager {
392
392
  session.lastCountedAt = undefined;
393
393
  session.lastActivity = Date.now();
394
394
  }
395
+ /**
396
+ * Persist a step's tool calls and results as `tool_call` / `tool_result`
397
+ * messages, mirroring the Redis manager.
398
+ *
399
+ * Parity fix: this used to exist only on the Redis backend, so an in-memory
400
+ * session never turned tool activity into messages and every downstream path
401
+ * that reasons about tool batches (compaction, pruning, pair repair) saw a
402
+ * different history shape depending on `STORAGE_TYPE`.
403
+ *
404
+ * Calls are written before results — the same order the Redis flush uses —
405
+ * and `toolCallId` is carried on BOTH sides so `repairToolPairs` can match by
406
+ * id rather than adjacency (a parallel batch has no positional pairing).
407
+ */
408
+ async storeToolExecution(sessionId, userId, toolCalls, toolResults, currentTime) {
409
+ await this.ensureInitialized();
410
+ let session = this.sessions.get(sessionId);
411
+ if (!session) {
412
+ session = this.createNewSession(sessionId, userId);
413
+ this.sessions.set(sessionId, session);
414
+ this.enforceSessionLimit();
415
+ }
416
+ const timestamp = (currentTime ?? new Date()).toISOString();
417
+ const toolNameById = new Map();
418
+ for (const toolCall of toolCalls ?? []) {
419
+ const toolCallId = toolCall.toolCallId ?? "";
420
+ const toolName = toolCall.toolName ?? "unknown";
421
+ if (toolCallId) {
422
+ toolNameById.set(toolCallId, toolName);
423
+ }
424
+ session.messages.push({
425
+ id: randomUUID(),
426
+ role: "tool_call",
427
+ content: "", // Tool calls carry their payload in `args`, not content.
428
+ tool: toolName,
429
+ ...(toolCallId ? { toolCallId } : {}),
430
+ args: (toolCall.args ?? {}),
431
+ timestamp,
432
+ });
433
+ }
434
+ for (const toolResult of toolResults ?? []) {
435
+ const toolCallId = toolResult.toolCallId ?? "";
436
+ const toolName = (toolCallId ? toolNameById.get(toolCallId) : undefined) ??
437
+ String(toolResult.toolName ?? "unknown");
438
+ const rawOutput = "output" in toolResult ? toolResult.output : toolResult.result;
439
+ let content;
440
+ if (typeof rawOutput === "string") {
441
+ content = rawOutput;
442
+ }
443
+ else {
444
+ try {
445
+ content = JSON.stringify(rawOutput ?? null) ?? "null";
446
+ }
447
+ catch (error) {
448
+ content = `[Serialization failed: ${error instanceof Error ? error.message : String(error)}]`;
449
+ }
450
+ }
451
+ session.messages.push({
452
+ id: randomUUID(),
453
+ role: "tool_result",
454
+ content,
455
+ tool: toolName,
456
+ ...(toolCallId ? { toolCallId } : {}),
457
+ result: {
458
+ success: !toolResult.error,
459
+ ...(toolResult.error ? { error: String(toolResult.error) } : {}),
460
+ },
461
+ timestamp,
462
+ });
463
+ }
464
+ session.lastActivity = Date.now();
465
+ }
395
466
  /** Close/shutdown — no-op for in-memory manager (no external connections to release) */
396
467
  async close() {
397
468
  // In-memory manager has nothing to close
@@ -93,6 +93,33 @@ export declare class RedisConversationMemoryManager implements IConversationMemo
93
93
  * Check if summarization is needed based on token count
94
94
  */
95
95
  private checkAndSummarize;
96
+ /**
97
+ * True only for keys holding a conversation BLOB — the sole key type these
98
+ * scan-then-GET paths may read.
99
+ *
100
+ * `${keyPrefix}*` also matches the companion message LISTs and, when a
101
+ * custom key prefix does not end in `conversation:`, the user-index SETs
102
+ * (whose derived prefix then collapses onto `keyPrefix`). `GET` against
103
+ * either raises WRONGTYPE, and counting them would inflate session totals.
104
+ */
105
+ private isConversationBlobKey;
106
+ /**
107
+ * Hydrate a deserialized blob's messages from the companion LIST when the
108
+ * session uses split storage. Legacy blobs (messages inline) pass through
109
+ * untouched — that is what makes the migration backward compatible.
110
+ */
111
+ private hydrateMessages;
112
+ /** Message count without materializing them — LLEN for split sessions. */
113
+ private countMessages;
114
+ /** Load a session, messages included, regardless of storage format. */
115
+ private loadConversation;
116
+ /**
117
+ * Persist a conversation, splitting messages into the companion LIST.
118
+ * `appendFrom` appends only messages from that index onward (the per-turn
119
+ * fast path); omit it to rewrite the LIST wholesale, which is also how a
120
+ * legacy blob gets converted.
121
+ */
122
+ private persistConversation;
96
123
  /**
97
124
  * Build context messages for AI prompt injection (TOKEN-BASED)
98
125
  * Returns messages from pointer onwards (or all if no pointer)
@@ -15,7 +15,7 @@ import { withTimeout } from "../utils/errorHandling.js";
15
15
  import { buildContextFromPointer, getEffectiveTokenThreshold, } from "../utils/conversationMemory.js";
16
16
  import { runWithCurrentLangfuseContext } from "../services/server/ai/observability/instrumentation.js";
17
17
  import { logger } from "../utils/logger.js";
18
- import { createRedisClient, deserializeConversation, getNormalizedConfig, getPooledRedisClient, getSessionKey, getUserSessionsKey, releasePooledRedisClient, scanKeys, serializeConversation, } from "../utils/redis.js";
18
+ import { createRedisClient, deserializeConversation, encodeStoredMessages, getSessionMessagesKey, MESSAGES_KEY_SUFFIX, isSessionMessagesKey, parseStoredMessages, serializeConversationMetadata, usesSplitMessageStorage, getNormalizedConfig, getPooledRedisClient, getSessionKey, getUserSessionsKey, releasePooledRedisClient, scanKeys, serializeConversation, } from "../utils/redis.js";
19
19
  const redisTracer = tracers.redis;
20
20
  const REDIS_TIMEOUT_MS = 5000;
21
21
  /**
@@ -140,7 +140,7 @@ export class RedisConversationMemoryManager {
140
140
  try {
141
141
  const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
142
142
  const conversationData = await withTimeout(redisClient.get(redisKey), REDIS_TIMEOUT_MS);
143
- const conversation = deserializeConversation(conversationData || null);
143
+ const conversation = await this.hydrateMessages(deserializeConversation(conversationData || null), redisKey);
144
144
  if (!conversation) {
145
145
  span.setAttribute("session.found", false);
146
146
  return undefined;
@@ -221,7 +221,7 @@ export class RedisConversationMemoryManager {
221
221
  }
222
222
  const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
223
223
  const conversationData = await this.redisClient.get(redisKey);
224
- return deserializeConversation(conversationData || null);
224
+ return this.hydrateMessages(deserializeConversation(conversationData || null), redisKey);
225
225
  }
226
226
  catch (error) {
227
227
  logger.error("[RedisConversationMemoryManager] Failed to get raw session", {
@@ -391,7 +391,13 @@ export class RedisConversationMemoryManager {
391
391
  }
392
392
  const redisKey = getSessionKey(this.redisConfig, options.sessionId, options.userId);
393
393
  const conversationData = await this.redisClient.get(redisKey);
394
- let conversation = deserializeConversation(conversationData);
394
+ let conversation = await this.hydrateMessages(deserializeConversation(conversationData), redisKey);
395
+ // Split-storage bookkeeping: whether this session already keeps its
396
+ // messages in the companion LIST, and how many it held before this
397
+ // turn — so only the new ones are appended rather than rewriting the
398
+ // whole conversation on every turn.
399
+ const wasSplitStorage = usesSplitMessageStorage(conversation);
400
+ const messageCountBeforeTurn = conversation?.messages.length ?? 0;
395
401
  const currentTime = new Date().toISOString();
396
402
  const normalizedUserId = options.userId || "randomUser";
397
403
  if (!conversation) {
@@ -486,6 +492,16 @@ export class RedisConversationMemoryManager {
486
492
  const shouldSummarize = options.enableSummarization !== undefined
487
493
  ? options.enableSummarization
488
494
  : this.config.enableSummarization;
495
+ // Append-only: push just this turn's messages and persist a SMALL
496
+ // metadata blob. A session not yet using split storage (new, or a
497
+ // legacy blob) is converted once here, then every later turn appends.
498
+ await this.persistConversation(conversation, options.sessionId, options.userId, wasSplitStorage ? messageCountBeforeTurn : undefined);
499
+ // Scheduled AFTER the write, never before it. On the single turn that
500
+ // converts a legacy session, `checkAndSummarize` re-reads the blob and
501
+ // writes it back; observing the pre-conversion blob would make it
502
+ // re-serialize the record WITHOUT the split marker, so a later SET
503
+ // would strip the marker while the companion LIST already held the
504
+ // messages — the next read would then serve the stale inline copy.
489
505
  if (shouldSummarize) {
490
506
  const normalizedUserId = options.userId || "randomUser";
491
507
  const summarizationKey = `${options.sessionId}:${normalizedUserId}`;
@@ -514,10 +530,8 @@ export class RedisConversationMemoryManager {
514
530
  });
515
531
  }
516
532
  }
517
- const serializedData = serializeConversation(conversation);
518
- await this.redisClient.set(redisKey, serializedData);
519
533
  // Log turn storage metadata for observability
520
- const blobSizeBytes = Buffer.byteLength(serializedData, "utf8");
534
+ const blobSizeBytes = Buffer.byteLength(serializeConversationMetadata(conversation), "utf8");
521
535
  logger.info("[ConversationMemory] Turn stored", {
522
536
  requestId: options.requestId,
523
537
  sessionId: options.sessionId,
@@ -611,7 +625,14 @@ export class RedisConversationMemoryManager {
611
625
  conversation.summarizedMessage;
612
626
  latestConversation.lastTokenCount = conversation.lastTokenCount;
613
627
  latestConversation.lastCountedAt = conversation.lastCountedAt;
614
- const freshSerialized = serializeConversation(latestConversation);
628
+ // Only summarization metadata changed, so the companion LIST is
629
+ // left alone. Critical: this re-read was NOT hydrated, so for a
630
+ // split session `messages` is empty — writing it with
631
+ // serializeConversation would drop the marker and make the record
632
+ // look like a legacy blob with zero messages (silent data loss).
633
+ const freshSerialized = usesSplitMessageStorage(latestConversation)
634
+ ? serializeConversationMetadata(latestConversation)
635
+ : serializeConversation(latestConversation);
615
636
  await this.redisClient.set(redisKey, freshSerialized);
616
637
  if (this.redisConfig.ttl > 0) {
617
638
  await this.redisClient.expire(redisKey, this.redisConfig.ttl);
@@ -631,6 +652,95 @@ export class RedisConversationMemoryManager {
631
652
  this.summarizationInProgress.delete(summarizationKey);
632
653
  }
633
654
  }
655
+ /**
656
+ * True only for keys holding a conversation BLOB — the sole key type these
657
+ * scan-then-GET paths may read.
658
+ *
659
+ * `${keyPrefix}*` also matches the companion message LISTs and, when a
660
+ * custom key prefix does not end in `conversation:`, the user-index SETs
661
+ * (whose derived prefix then collapses onto `keyPrefix`). `GET` against
662
+ * either raises WRONGTYPE, and counting them would inflate session totals.
663
+ */
664
+ isConversationBlobKey(key) {
665
+ return (!isSessionMessagesKey(key) &&
666
+ !key.endsWith(":sessions") &&
667
+ !key.startsWith(this.redisConfig.userSessionsKeyPrefix));
668
+ }
669
+ /**
670
+ * Hydrate a deserialized blob's messages from the companion LIST when the
671
+ * session uses split storage. Legacy blobs (messages inline) pass through
672
+ * untouched — that is what makes the migration backward compatible.
673
+ */
674
+ async hydrateMessages(conversation, redisKey) {
675
+ if (!conversation || !usesSplitMessageStorage(conversation)) {
676
+ return conversation;
677
+ }
678
+ if (!this.redisClient) {
679
+ return conversation;
680
+ }
681
+ // `getSession` and `buildContextMessages` guard their own GET with
682
+ // `withTimeout`; both now also depend on this read, so leaving it unguarded
683
+ // would let a hung LIST read block the request thread anyway.
684
+ const entries = await withTimeout(this.redisClient.lRange(`${redisKey}${MESSAGES_KEY_SUFFIX}`, 0, -1), REDIS_TIMEOUT_MS);
685
+ // `lRange` is typed `(string | Buffer)[]` — the client returns Buffers when
686
+ // a connection is opened in binary mode. Normalize rather than assert, so
687
+ // a binary-mode client reads its history instead of throwing on parse.
688
+ conversation.messages = parseStoredMessages(entries.map((entry) => entry.toString()));
689
+ return conversation;
690
+ }
691
+ /** Message count without materializing them — LLEN for split sessions. */
692
+ async countMessages(conversation, redisKey) {
693
+ if (!usesSplitMessageStorage(conversation) || !this.redisClient) {
694
+ return conversation.messages.length;
695
+ }
696
+ // `lLen` is typed `number | \`${number}\`` (RESP3 can deliver it as a
697
+ // string), and callers add it to numbers — coerce so the count never
698
+ // concatenates instead of summing.
699
+ return Number(await withTimeout(this.redisClient.lLen(`${redisKey}${MESSAGES_KEY_SUFFIX}`), REDIS_TIMEOUT_MS));
700
+ }
701
+ /** Load a session, messages included, regardless of storage format. */
702
+ async loadConversation(sessionId, userId) {
703
+ if (!this.redisClient) {
704
+ return null;
705
+ }
706
+ const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
707
+ return this.hydrateMessages(deserializeConversation(await this.redisClient.get(redisKey)), redisKey);
708
+ }
709
+ /**
710
+ * Persist a conversation, splitting messages into the companion LIST.
711
+ * `appendFrom` appends only messages from that index onward (the per-turn
712
+ * fast path); omit it to rewrite the LIST wholesale, which is also how a
713
+ * legacy blob gets converted.
714
+ */
715
+ async persistConversation(conversation, sessionId, userId, appendFrom) {
716
+ if (!this.redisClient) {
717
+ return;
718
+ }
719
+ const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
720
+ const messagesKey = getSessionMessagesKey(this.redisConfig, sessionId, userId);
721
+ // One MULTI, not four to six independent commands. The replace path is a
722
+ // DEL followed by an RPUSH, and on the legacy-conversion path the inline
723
+ // blob is the only copy of the history until the SET lands — so a failure
724
+ // between any two of them leaves the session either empty or unrecoverable.
725
+ // Grouping them also collapses the round trips, which is the point of the
726
+ // append-only layout.
727
+ const tx = this.redisClient.multi();
728
+ if (appendFrom === undefined) {
729
+ tx.del(messagesKey);
730
+ }
731
+ const toPush = appendFrom === undefined
732
+ ? conversation.messages
733
+ : conversation.messages.slice(appendFrom);
734
+ if (toPush.length > 0) {
735
+ tx.rPush(messagesKey, encodeStoredMessages(toPush));
736
+ }
737
+ tx.set(redisKey, serializeConversationMetadata(conversation));
738
+ if (this.redisConfig.ttl > 0) {
739
+ tx.expire(redisKey, this.redisConfig.ttl);
740
+ tx.expire(messagesKey, this.redisConfig.ttl);
741
+ }
742
+ await withTimeout(tx.exec(), REDIS_TIMEOUT_MS);
743
+ }
634
744
  /**
635
745
  * Build context messages for AI prompt injection (TOKEN-BASED)
636
746
  * Returns messages from pointer onwards (or all if no pointer)
@@ -664,7 +774,7 @@ export class RedisConversationMemoryManager {
664
774
  });
665
775
  const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
666
776
  const conversationData = await withTimeout(redisClient.get(redisKey), REDIS_TIMEOUT_MS);
667
- const conversation = deserializeConversation(conversationData || null);
777
+ const conversation = await this.hydrateMessages(deserializeConversation(conversationData || null), redisKey);
668
778
  logger.debug("[RedisConversationMemoryManager] Retrieved conversation for context building", {
669
779
  sessionId,
670
780
  userId,
@@ -895,7 +1005,7 @@ export class RedisConversationMemoryManager {
895
1005
  return null;
896
1006
  }
897
1007
  // Deserialize the complete conversation object
898
- const conversation = deserializeConversation(conversationData);
1008
+ const conversation = await this.hydrateMessages(deserializeConversation(conversationData), sessionKey);
899
1009
  if (!conversation) {
900
1010
  logger.debug("[RedisConversationMemoryManager] Failed to deserialize conversation data", {
901
1011
  userId,
@@ -1014,7 +1124,7 @@ User message: "${userMessage}"`;
1014
1124
  try {
1015
1125
  const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
1016
1126
  const conversationData = await this.redisClient.get(redisKey);
1017
- const conversation = deserializeConversation(conversationData || null);
1127
+ const conversation = await this.hydrateMessages(deserializeConversation(conversationData || null), redisKey);
1018
1128
  return conversation?.messages ?? [];
1019
1129
  }
1020
1130
  catch (error) {
@@ -1050,11 +1160,8 @@ User message: "${userMessage}"`;
1050
1160
  conversation.summarizedMessage = undefined;
1051
1161
  conversation.lastTokenCount = undefined;
1052
1162
  conversation.lastCountedAt = undefined;
1053
- const serializedData = serializeConversation(conversation);
1054
- await this.redisClient.set(redisKey, serializedData);
1055
- if (this.redisConfig.ttl > 0) {
1056
- await this.redisClient.expire(redisKey, this.redisConfig.ttl);
1057
- }
1163
+ // Wholesale replacement: rewrite the LIST rather than appending.
1164
+ await this.persistConversation(conversation, sessionId, userId);
1058
1165
  logger.debug("[RedisConversationMemoryManager] Session messages replaced", {
1059
1166
  sessionId,
1060
1167
  userId,
@@ -1097,11 +1204,16 @@ User message: "${userMessage}"`;
1097
1204
  pattern,
1098
1205
  keyCount: keys.length,
1099
1206
  });
1207
+ // Companion message LISTs share the session key prefix, so the SCAN above
1208
+ // returns them too. GET on a LIST raises WRONGTYPE, and counting them as
1209
+ // sessions would double `totalSessions` — filter them out exactly as the
1210
+ // session listing already skips `:sessions` index keys.
1211
+ const sessionKeys = keys.filter((key) => this.isConversationBlobKey(key));
1100
1212
  // Count messages in each session
1101
1213
  let totalTurns = 0;
1102
- for (const key of keys) {
1214
+ for (const key of sessionKeys) {
1103
1215
  const conversationData = await this.redisClient.get(key);
1104
- const conversation = deserializeConversation(conversationData);
1216
+ const conversation = await this.hydrateMessages(deserializeConversation(conversationData), key);
1105
1217
  if (conversation?.messages) {
1106
1218
  // Pinned skill messages are extra rows inside a turn — exclude them
1107
1219
  // so a skill-activating turn still counts as one turn.
@@ -1110,7 +1222,7 @@ User message: "${userMessage}"`;
1110
1222
  }
1111
1223
  }
1112
1224
  return {
1113
- totalSessions: keys.length,
1225
+ totalSessions: sessionKeys.length,
1114
1226
  totalTurns,
1115
1227
  };
1116
1228
  }
@@ -1133,7 +1245,9 @@ User message: "${userMessage}"`;
1133
1245
  }, async (span) => {
1134
1246
  try {
1135
1247
  const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
1136
- const result = await withTimeout(redisClient.del(redisKey), REDIS_TIMEOUT_MS);
1248
+ // Delete the companion message LIST as well: leaving it behind would
1249
+ // leak the messages and let a re-created session inherit them.
1250
+ const result = await withTimeout(redisClient.del([redisKey, `${redisKey}${MESSAGES_KEY_SUFFIX}`]), REDIS_TIMEOUT_MS);
1137
1251
  if (Number(result) > 0) {
1138
1252
  // Remove session from user's session set
1139
1253
  if (userId) {
@@ -1307,8 +1421,9 @@ User message: "${userMessage}"`;
1307
1421
  // List all sessions across all users by scanning Redis keys
1308
1422
  const keys = await scanKeys(this.redisClient, `${this.redisConfig.keyPrefix}*`);
1309
1423
  for (const key of keys) {
1310
- // Skip user session index keys (they end with :sessions)
1311
- if (key.endsWith(":sessions")) {
1424
+ // Skip user session index keys (they end with :sessions) and the
1425
+ // companion message LISTs — GET on a LIST raises WRONGTYPE.
1426
+ if (!this.isConversationBlobKey(key)) {
1312
1427
  continue;
1313
1428
  }
1314
1429
  const raw = await this.redisClient.get(key);
@@ -1325,7 +1440,9 @@ User message: "${userMessage}"`;
1325
1440
  createdAt: session.createdAt,
1326
1441
  updatedAt: session.updatedAt,
1327
1442
  userId: session.userId,
1328
- messageCount: session.messages.length,
1443
+ // LLEN for split sessions: the blob's inline array is empty here
1444
+ // because this branch reads the raw key without hydrating.
1445
+ messageCount: await this.countMessages(session, key),
1329
1446
  lastActive: this.formatTimeAgo(now - new Date(session.updatedAt).getTime()),
1330
1447
  });
1331
1448
  }
@@ -1571,8 +1688,12 @@ User message: "${userMessage}"`;
1571
1688
  logger.debug("[RedisConversationMemoryManager] Added new agentic loop report", { sessionId, reportId: report.reportId });
1572
1689
  }
1573
1690
  conversation.updatedAt = new Date().toISOString();
1574
- // Write back to Redis
1575
- const serializedData = serializeConversation(conversation);
1691
+ // Write back to Redis. Metadata-only change, so the companion LIST is
1692
+ // untouched but the split marker must survive (see the summarization
1693
+ // writeback above for why dropping it loses messages).
1694
+ const serializedData = usesSplitMessageStorage(conversation)
1695
+ ? serializeConversationMetadata(conversation)
1696
+ : serializeConversation(conversation);
1576
1697
  await withTimeout(this.redisClient.set(redisKey, serializedData), 5000);
1577
1698
  if (this.redisConfig.ttl > 0) {
1578
1699
  await withTimeout(this.redisClient.expire(redisKey, this.redisConfig.ttl), 5000);
@@ -1787,8 +1787,14 @@ export declare class NeuroLink {
1787
1787
  [key: string]: unknown;
1788
1788
  }>, currentTime?: Date): Promise<void>;
1789
1789
  /**
1790
- * Check if tool execution storage is available
1791
- * @returns boolean indicating if Redis storage is configured and available
1790
+ * Check if tool execution storage is available.
1791
+ *
1792
+ * Now capability-based rather than Redis-specific: any configured memory
1793
+ * backend implementing `storeToolExecution` qualifies. The old check
1794
+ * required `STORAGE_TYPE === "redis"` AND a Redis manager by class name, so
1795
+ * in-memory sessions reported false and silently skipped tool persistence.
1796
+ *
1797
+ * @returns whether the active memory backend can persist tool executions
1792
1798
  */
1793
1799
  isToolExecutionStorageAvailable(): boolean;
1794
1800
  /**
@@ -11129,11 +11129,16 @@ Current user's request: ${currentInput}`;
11129
11129
  });
11130
11130
  return;
11131
11131
  }
11132
- // Type guard to ensure it's Redis conversation memory manager
11133
- const redisMemory = this
11134
- .conversationMemory;
11132
+ // Any backend that implements storeToolExecution no longer a Redis cast.
11133
+ // The in-memory manager implements it too, so tool activity becomes
11134
+ // tool_call/tool_result messages regardless of STORAGE_TYPE.
11135
+ const memory = this.conversationMemory;
11136
+ if (!memory?.storeToolExecution) {
11137
+ logger.debug("Tool execution storage not supported by this memory backend");
11138
+ return;
11139
+ }
11135
11140
  try {
11136
- await redisMemory.storeToolExecution(sessionId, userId, toolCalls, toolResults, currentTime);
11141
+ await memory.storeToolExecution(sessionId, userId, toolCalls, toolResults, currentTime);
11137
11142
  }
11138
11143
  catch (error) {
11139
11144
  logger.warn("Failed to store tool executions", {
@@ -11145,15 +11150,17 @@ Current user's request: ${currentInput}`;
11145
11150
  }
11146
11151
  }
11147
11152
  /**
11148
- * Check if tool execution storage is available
11149
- * @returns boolean indicating if Redis storage is configured and available
11153
+ * Check if tool execution storage is available.
11154
+ *
11155
+ * Now capability-based rather than Redis-specific: any configured memory
11156
+ * backend implementing `storeToolExecution` qualifies. The old check
11157
+ * required `STORAGE_TYPE === "redis"` AND a Redis manager by class name, so
11158
+ * in-memory sessions reported false and silently skipped tool persistence.
11159
+ *
11160
+ * @returns whether the active memory backend can persist tool executions
11150
11161
  */
11151
11162
  isToolExecutionStorageAvailable() {
11152
- const isRedisStorage = process.env.STORAGE_TYPE === "redis";
11153
- const hasRedisConversationMemory = this.conversationMemory &&
11154
- this.conversationMemory.constructor.name ===
11155
- "RedisConversationMemoryManager";
11156
- return !!(isRedisStorage && hasRedisConversationMemory);
11163
+ return typeof this.conversationMemory?.storeToolExecution === "function";
11157
11164
  }
11158
11165
  /**
11159
11166
  * Get the raw messages array for a session.
@@ -2,37 +2,6 @@ import { type AIProviderName } from "../../constants/enums.js";
2
2
  import { BaseProvider } from "../../core/baseProvider.js";
3
3
  import type { ZodUnknownSchema, EnhancedGenerateResult, TextGenerationOptions, StreamOptions, StreamResult } from "../../types/index.js";
4
4
  import type { LanguageModel, Schema } from "../../types/index.js";
5
- /**
6
- * Google AI Studio provider implementation using BaseProvider
7
- * Migrated from original GoogleAIStudio class to new factory pattern
8
- *
9
- * @important Structured Output Limitation
10
- * Google Gemini models cannot combine function calling (tools) with structured
11
- * output (JSON schema). When using schemas with output.format: "json", you MUST
12
- * set disableTools: true.
13
- *
14
- * Error without disableTools:
15
- * "Function calling with a response mime type: 'application/json' is unsupported"
16
- *
17
- * This is a Google API limitation documented at:
18
- * https://ai.google.dev/gemini-api/docs/function-calling
19
- *
20
- * @example
21
- * ```typescript
22
- * // ✅ Correct usage with schemas
23
- * const provider = new GoogleAIStudioProvider("gemini-2.5-flash");
24
- * const result = await provider.generate({
25
- * input: { text: "Analyze data" },
26
- * schema: MySchema,
27
- * output: { format: "json" },
28
- * disableTools: true // Required
29
- * });
30
- * ```
31
- *
32
- * @note Gemini 3 Pro Preview (November 2025) will support combining tools + schemas
33
- * @note "Too many states for serving" errors can occur with complex schemas + tools.
34
- * Solution: Simplify schema or use disableTools: true
35
- */
36
5
  export declare class GoogleAIStudioProvider extends BaseProvider {
37
6
  private credentials?;
38
7
  constructor(modelName?: string, sdk?: unknown, credentials?: {