@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
@@ -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[];
@@ -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 isValidHistory = conversation.messages.every((m) => typeof m === "object" &&
239
- m !== null &&
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,4 +447,84 @@ 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
+ }
399
530
  //# sourceMappingURL=redis.js.map
@@ -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
  /**
package/dist/neurolink.js CHANGED
@@ -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?: {
@@ -6,12 +6,14 @@ import { ATTR, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "
6
6
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
7
7
  import { ERROR_CODES, NeuroLinkError } from "../../utils/errorHandling.js";
8
8
  import { logger } from "../../utils/logger.js";
9
+ import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
10
+ import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
9
11
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../../utils/timeout.js";
10
12
  import { withTimeout } from "../../utils/async/index.js";
11
13
  import { estimateTokens } from "../../utils/tokenEstimation.js";
12
14
  import { transformToolExecutions } from "../../utils/transformationUtils.js";
13
15
  import { resolveToolExecutionRecords } from "../../core/toolExecutionRecorder.js";
14
- import { buildGeminiResponseSchema, buildNativeConfig, buildNativeToolDeclarations, collectStreamChunks, collectStreamChunksIncremental, computeMaxSteps, createTextChannel, buildUserPartsWithMultimodal, executeNativeToolCalls, extractTextFromParts, extractThoughtSignature, handleMaxStepsTermination, prependConversationMessages, pushModelResponseToHistory, refreshNativeToolDeclarations, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
16
+ import { buildGeminiResponseSchema, buildNativeConfig, buildNativeToolDeclarations, collectStreamChunks, collectStreamChunksIncremental, computeMaxSteps, createContextGuard, createTextChannel, buildUserPartsWithMultimodal, executeNativeToolCalls, extractTextFromParts, extractThoughtSignature, handleMaxStepsTermination, prependConversationMessages, pushModelResponseToHistory, refreshNativeToolDeclarations, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
15
17
  import { createProxyFetch } from "../../proxy/proxyFetch.js";
16
18
  // Google AI Live API types now imported from ../types/providerSpecific.js
17
19
  // Import proper types for multimodal message handling
@@ -69,6 +71,74 @@ async function createGoogleGenAIClient(apiKey) {
69
71
  * @note "Too many states for serving" errors can occur with complex schemas + tools.
70
72
  * Solution: Simplify schema or use disableTools: true
71
73
  */
74
+ /**
75
+ * Reclaim context from an AI Studio loop history IN PLACE.
76
+ *
77
+ * This loop had NO in-turn guard at all — it appended a model turn plus a tool
78
+ * turn every step with nothing bounding growth, so a long agentic run walked
79
+ * into a provider "context length exceeded" and lost every completed step.
80
+ * Shares its reclaim policy with the other provider loops via loopGuardCore.
81
+ *
82
+ * Returns true when something was reclaimed.
83
+ */
84
+ function reclaimAiStudioContext(contents, modelName, observedPromptTokens) {
85
+ const plan = planGeminiLoopReclaim({
86
+ contents,
87
+ availableInputTokens: getAvailableInputTokens("googleAiStudio", modelName),
88
+ provider: "googleAiStudio",
89
+ ...(observedPromptTokens ? { observedPromptTokens } : {}),
90
+ });
91
+ if (!plan) {
92
+ return false;
93
+ }
94
+ const dropSet = new Set(plan.drop);
95
+ const truncateSet = new Set(plan.truncate);
96
+ const rebuilt = [];
97
+ for (let i = 0; i < contents.length; i++) {
98
+ if (dropSet.has(i)) {
99
+ continue;
100
+ }
101
+ const content = contents[i];
102
+ if (truncateSet.has(i) && Array.isArray(content.parts)) {
103
+ rebuilt.push({
104
+ ...content,
105
+ parts: content.parts.map((part) => {
106
+ const record = part;
107
+ if (!record.functionResponse) {
108
+ return part;
109
+ }
110
+ const text = JSON.stringify(record.functionResponse.response) ?? "";
111
+ if (text.length <= 2048) {
112
+ return part;
113
+ }
114
+ return {
115
+ functionResponse: {
116
+ name: record.functionResponse.name,
117
+ response: { result: previewGeminiToolResponseText(text) },
118
+ },
119
+ };
120
+ }),
121
+ });
122
+ continue;
123
+ }
124
+ rebuilt.push(content);
125
+ }
126
+ if (dropSet.size > 0) {
127
+ let noteIndex = rebuilt.findIndex((c) => Array.isArray(c.parts) &&
128
+ c.parts.some((part) => !!part.functionCall ||
129
+ !!part.functionResponse));
130
+ if (noteIndex < 0) {
131
+ noteIndex = Math.min(1, rebuilt.length);
132
+ }
133
+ rebuilt.splice(noteIndex, 0, {
134
+ role: "user",
135
+ parts: [{ text: GEMINI_ELISION_NOTE }],
136
+ });
137
+ }
138
+ contents.length = 0;
139
+ contents.push(...rebuilt);
140
+ return true;
141
+ }
72
142
  export class GoogleAIStudioProvider extends BaseProvider {
73
143
  credentials;
74
144
  constructor(modelName, sdk, credentials) {
@@ -595,9 +665,27 @@ export class GoogleAIStudioProvider extends BaseProvider {
595
665
  let step = 0;
596
666
  let completedWithFinalAnswer = false;
597
667
  const failedTools = new Map();
668
+ // Cheap trigger for the in-turn reclaim, mirroring the Vertex twin.
669
+ // Planning serializes the WHOLE accumulated history to estimate it,
670
+ // so running it unconditionally charges that once per step for the
671
+ // life of the turn; the guard tracks real prompt counts plus
672
+ // measured growth instead, and it supplies the observed count that
673
+ // calibrates the planner's char estimate.
674
+ const contextGuard = createContextGuard(getContextWindowSize("googleAiStudio", modelName));
598
675
  try {
599
676
  // Agentic loop for tool calling
600
677
  while (step < maxSteps) {
678
+ // In-turn context guard: this loop appends a model turn plus a
679
+ // tool turn every step with nothing bounding growth. No-op
680
+ // while the request still fits, so a loop that fits never pays
681
+ // a cache invalidation. Step 0 still plans unconditionally —
682
+ // the guard has no usage to go on yet, and the incoming history
683
+ // can already be oversized before the first call.
684
+ if (step === 0 || contextGuard.shouldStop()) {
685
+ if (reclaimAiStudioContext(currentContents, modelName, contextGuard.projectedNextPromptTokens)) {
686
+ contextGuard.resetAfterReclaim();
687
+ }
688
+ }
601
689
  if (composedSignal?.aborted) {
602
690
  throw composedSignal.reason instanceof Error
603
691
  ? composedSignal.reason
@@ -629,6 +717,10 @@ export class GoogleAIStudioProvider extends BaseProvider {
629
717
  totalOutputTokens += chunkResult.outputTokens;
630
718
  totalCacheReadTokens += chunkResult.cacheReadTokens ?? 0;
631
719
  totalReasoningTokens += chunkResult.reasoningTokens ?? 0;
720
+ // `inputTokens` is this step's promptTokenCount — the FULL
721
+ // prompt size for the request just made, which is what the
722
+ // guard projects the next request from.
723
+ contextGuard.noteUsage(chunkResult.inputTokens, chunkResult.outputTokens);
632
724
  const stepText = extractTextFromParts(chunkResult.rawResponseParts);
633
725
  // If no function calls, this was the final step — channel
634
726
  // already received all text parts incrementally.
@@ -691,6 +783,14 @@ export class GoogleAIStudioProvider extends BaseProvider {
691
783
  role: "user",
692
784
  parts: functionResponses,
693
785
  });
786
+ // Project this step's growth: the appended tool results ride
787
+ // the next prompt, which the provider has not reported on yet.
788
+ try {
789
+ contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
790
+ }
791
+ catch {
792
+ /* estimation is best-effort — never break the loop */
793
+ }
694
794
  }
695
795
  catch (error) {
696
796
  logger.error("[GoogleAIStudio] Native SDK error", error);
@@ -877,8 +977,16 @@ export class GoogleAIStudioProvider extends BaseProvider {
877
977
  const toolExecutions = [];
878
978
  let step = 0;
879
979
  const failedTools = new Map();
980
+ // Cheap reclaim trigger — see the stream twin.
981
+ const contextGuard = createContextGuard(getContextWindowSize("googleAiStudio", modelName));
880
982
  // Agentic loop for tool calling
881
983
  while (step < maxSteps) {
984
+ // In-turn context guard — see the stream twin.
985
+ if (step === 0 || contextGuard.shouldStop()) {
986
+ if (reclaimAiStudioContext(currentContents, modelName, contextGuard.projectedNextPromptTokens)) {
987
+ contextGuard.resetAfterReclaim();
988
+ }
989
+ }
882
990
  if (composedSignal?.aborted) {
883
991
  throw composedSignal.reason instanceof Error
884
992
  ? composedSignal.reason
@@ -904,6 +1012,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
904
1012
  totalOutputTokens += chunkResult.outputTokens;
905
1013
  totalCacheReadTokens += chunkResult.cacheReadTokens ?? 0;
906
1014
  totalReasoningTokens += chunkResult.reasoningTokens ?? 0;
1015
+ contextGuard.noteUsage(chunkResult.inputTokens, chunkResult.outputTokens);
907
1016
  const stepText = extractTextFromParts(chunkResult.rawResponseParts);
908
1017
  // If no function calls, we're done
909
1018
  if (chunkResult.stepFunctionCalls.length === 0) {
@@ -961,6 +1070,14 @@ export class GoogleAIStudioProvider extends BaseProvider {
961
1070
  role: "user",
962
1071
  parts: functionResponses,
963
1072
  });
1073
+ // Project this step's growth: the appended tool results ride
1074
+ // the next prompt, which the provider has not reported on yet.
1075
+ try {
1076
+ contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
1077
+ }
1078
+ catch {
1079
+ /* estimation is best-effort — never break the loop */
1080
+ }
964
1081
  }
965
1082
  catch (error) {
966
1083
  logger.error("[GoogleAIStudio] Native SDK generate error", error);
@@ -385,6 +385,15 @@ export declare function createContextGuard(contextWindowTokens: number, threshol
385
385
  * results, nudge text) using the ~4 chars/token heuristic.
386
386
  */
387
387
  noteAppendedChars(chars: number): void;
388
+ /**
389
+ * Clear the projection after the caller has reclaimed context.
390
+ *
391
+ * The observed prompt size reflects the pre-reclaim conversation, so
392
+ * leaving it in place would keep `shouldStop()` true forever and defeat
393
+ * the reclaim. Resetting to the fail-open state means the guard stays
394
+ * quiet until the next real usage report re-establishes the truth.
395
+ */
396
+ resetAfterReclaim(): void;
388
397
  /** True when issuing another model call risks crossing the threshold. */
389
398
  shouldStop(): boolean;
390
399
  };
@@ -1229,6 +1229,18 @@ export function createContextGuard(contextWindowTokens, thresholdRatio = DEFAULT
1229
1229
  projectedGrowthTokens += Math.ceil(chars / 4);
1230
1230
  }
1231
1231
  },
1232
+ /**
1233
+ * Clear the projection after the caller has reclaimed context.
1234
+ *
1235
+ * The observed prompt size reflects the pre-reclaim conversation, so
1236
+ * leaving it in place would keep `shouldStop()` true forever and defeat
1237
+ * the reclaim. Resetting to the fail-open state means the guard stays
1238
+ * quiet until the next real usage report re-establishes the truth.
1239
+ */
1240
+ resetAfterReclaim() {
1241
+ observedPromptTokens = 0;
1242
+ projectedGrowthTokens = 0;
1243
+ },
1232
1244
  /** True when issuing another model call risks crossing the threshold. */
1233
1245
  shouldStop() {
1234
1246
  return (observedPromptTokens > 0 &&
@@ -51,51 +51,6 @@ export declare function stripAdditionalPropertiesDeep(schema: Record<string, unk
51
51
  * @returns The region string to pass to the @google/genai client.
52
52
  */
53
53
  export declare const resolveVertexLocation: (modelName: string | undefined, configuredLocation?: string) => string;
54
- /**
55
- * Google Vertex AI Provider v2 - BaseProvider Implementation
56
- *
57
- * Features:
58
- * - Extends BaseProvider for shared functionality
59
- * - Preserves existing Google Cloud authentication
60
- * - Maintains Anthropic model support via dynamic imports
61
- * - Fresh model creation for each request
62
- * - Enhanced error handling with setup guidance
63
- * - Tool registration and context management
64
- *
65
- * @important Tools + Schema Support (Fixed)
66
- * Gemini models on Vertex AI now support combining function calling (tools) with
67
- * structured output (JSON schema) simultaneously. The fix works by NOT setting
68
- * `responseMimeType: "application/json"` when tools are present, which was
69
- * causing the Google API error.
70
- *
71
- * The `responseSchema` is still set to guide the output structure, allowing
72
- * tools to execute AND the final output to follow the schema format.
73
- *
74
- * @example Gemini models with tools + schemas
75
- * ```typescript
76
- * const provider = new GoogleVertexProvider("gemini-2.5-flash");
77
- * const result = await provider.generate({
78
- * input: { text: "Analyze data using tools" },
79
- * schema: MySchema,
80
- * output: { format: "json" },
81
- * // No need for disableTools: true anymore!
82
- * });
83
- * ```
84
- *
85
- * @example Claude models (always supported both)
86
- * ```typescript
87
- * const provider = new GoogleVertexProvider("claude-3-5-sonnet-20241022");
88
- * const result = await provider.generate({
89
- * input: { text: "Analyze data" },
90
- * schema: MySchema,
91
- * output: { format: "json" }
92
- * });
93
- * ```
94
- *
95
- * @note "Too many states for serving" errors can still occur with very complex schemas + tools.
96
- * Solution: Simplify schema or reduce number of tools if this occurs.
97
- * @see https://cloud.google.com/vertex-ai/docs/generative-ai/learn/models
98
- */
99
54
  export declare class GoogleVertexProvider extends BaseProvider {
100
55
  private projectId;
101
56
  private location;