@vybestack/llxprt-code-core 0.1.23 → 0.2.2-nightly.250908.7b895396

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 (153) hide show
  1. package/README.md +21 -17
  2. package/dist/src/adapters/IStreamAdapter.d.ts +3 -3
  3. package/dist/src/auth/oauth-errors.d.ts +173 -0
  4. package/dist/src/auth/oauth-errors.js +461 -0
  5. package/dist/src/auth/oauth-errors.js.map +1 -0
  6. package/dist/src/auth/precedence.d.ts +1 -5
  7. package/dist/src/auth/precedence.js +28 -48
  8. package/dist/src/auth/precedence.js.map +1 -1
  9. package/dist/src/auth/token-store.js +2 -2
  10. package/dist/src/auth/token-store.js.map +1 -1
  11. package/dist/src/auth/types.d.ts +4 -4
  12. package/dist/src/code_assist/codeAssist.js +19 -6
  13. package/dist/src/code_assist/codeAssist.js.map +1 -1
  14. package/dist/src/code_assist/oauth2.d.ts +7 -0
  15. package/dist/src/code_assist/oauth2.js +82 -32
  16. package/dist/src/code_assist/oauth2.js.map +1 -1
  17. package/dist/src/code_assist/server.js +15 -4
  18. package/dist/src/code_assist/server.js.map +1 -1
  19. package/dist/src/code_assist/setup.js +9 -0
  20. package/dist/src/code_assist/setup.js.map +1 -1
  21. package/dist/src/config/index.d.ts +7 -0
  22. package/dist/src/config/index.js +8 -0
  23. package/dist/src/config/index.js.map +1 -0
  24. package/dist/src/core/client.d.ts +15 -20
  25. package/dist/src/core/client.js +98 -124
  26. package/dist/src/core/client.js.map +1 -1
  27. package/dist/src/core/compression-config.d.ts +10 -0
  28. package/dist/src/core/compression-config.js +17 -0
  29. package/dist/src/core/compression-config.js.map +1 -0
  30. package/dist/src/core/coreToolScheduler.js +50 -15
  31. package/dist/src/core/coreToolScheduler.js.map +1 -1
  32. package/dist/src/core/geminiChat.d.ts +68 -9
  33. package/dist/src/core/geminiChat.js +940 -405
  34. package/dist/src/core/geminiChat.js.map +1 -1
  35. package/dist/src/core/nonInteractiveToolExecutor.js +70 -19
  36. package/dist/src/core/nonInteractiveToolExecutor.js.map +1 -1
  37. package/dist/src/core/prompts.js +35 -25
  38. package/dist/src/core/prompts.js.map +1 -1
  39. package/dist/src/core/turn.d.ts +1 -0
  40. package/dist/src/core/turn.js +8 -6
  41. package/dist/src/core/turn.js.map +1 -1
  42. package/dist/src/ide/ide-client.d.ts +1 -1
  43. package/dist/src/ide/ide-client.js +12 -6
  44. package/dist/src/ide/ide-client.js.map +1 -1
  45. package/dist/src/index.d.ts +4 -2
  46. package/dist/src/index.js +5 -2
  47. package/dist/src/index.js.map +1 -1
  48. package/dist/src/prompt-config/TemplateEngine.js +17 -0
  49. package/dist/src/prompt-config/TemplateEngine.js.map +1 -1
  50. package/dist/src/prompt-config/defaults/core-defaults.js +39 -32
  51. package/dist/src/prompt-config/defaults/core-defaults.js.map +1 -1
  52. package/dist/src/prompt-config/defaults/core.md +2 -0
  53. package/dist/src/prompt-config/defaults/provider-defaults.js +34 -27
  54. package/dist/src/prompt-config/defaults/provider-defaults.js.map +1 -1
  55. package/dist/src/prompt-config/defaults/providers/gemini/core.md +270 -0
  56. package/dist/src/prompt-config/defaults/providers/gemini/models/gemini-2.5-flash/core.md +12 -0
  57. package/dist/src/prompt-config/defaults/providers/gemini/models/gemini-2.5-flash/gemini-2-5-flash/core.md +12 -0
  58. package/dist/src/prompt-config/types.d.ts +2 -0
  59. package/dist/src/providers/BaseProvider.d.ts +39 -13
  60. package/dist/src/providers/BaseProvider.js +102 -28
  61. package/dist/src/providers/BaseProvider.js.map +1 -1
  62. package/dist/src/providers/IProvider.d.ts +17 -3
  63. package/dist/src/providers/LoggingProviderWrapper.d.ts +10 -3
  64. package/dist/src/providers/LoggingProviderWrapper.js +33 -27
  65. package/dist/src/providers/LoggingProviderWrapper.js.map +1 -1
  66. package/dist/src/providers/ProviderContentGenerator.d.ts +2 -2
  67. package/dist/src/providers/ProviderContentGenerator.js +9 -6
  68. package/dist/src/providers/ProviderContentGenerator.js.map +1 -1
  69. package/dist/src/providers/ProviderManager.d.ts +4 -0
  70. package/dist/src/providers/ProviderManager.js +6 -0
  71. package/dist/src/providers/ProviderManager.js.map +1 -1
  72. package/dist/src/providers/anthropic/AnthropicProvider.d.ts +34 -21
  73. package/dist/src/providers/anthropic/AnthropicProvider.js +505 -492
  74. package/dist/src/providers/anthropic/AnthropicProvider.js.map +1 -1
  75. package/dist/src/providers/gemini/GeminiProvider.d.ts +23 -9
  76. package/dist/src/providers/gemini/GeminiProvider.js +344 -515
  77. package/dist/src/providers/gemini/GeminiProvider.js.map +1 -1
  78. package/dist/src/providers/openai/ConversationCache.d.ts +3 -3
  79. package/dist/src/providers/openai/IChatGenerateParams.d.ts +9 -4
  80. package/dist/src/providers/openai/OpenAIProvider.d.ts +46 -96
  81. package/dist/src/providers/openai/OpenAIProvider.js +580 -1392
  82. package/dist/src/providers/openai/OpenAIProvider.js.map +1 -1
  83. package/dist/src/providers/openai/buildResponsesRequest.d.ts +3 -3
  84. package/dist/src/providers/openai/buildResponsesRequest.js +67 -37
  85. package/dist/src/providers/openai/buildResponsesRequest.js.map +1 -1
  86. package/dist/src/providers/openai/estimateRemoteTokens.d.ts +2 -2
  87. package/dist/src/providers/openai/estimateRemoteTokens.js +21 -8
  88. package/dist/src/providers/openai/estimateRemoteTokens.js.map +1 -1
  89. package/dist/src/providers/openai/parseResponsesStream.d.ts +6 -2
  90. package/dist/src/providers/openai/parseResponsesStream.js +99 -391
  91. package/dist/src/providers/openai/parseResponsesStream.js.map +1 -1
  92. package/dist/src/providers/openai/syntheticToolResponses.d.ts +5 -5
  93. package/dist/src/providers/openai/syntheticToolResponses.js +102 -91
  94. package/dist/src/providers/openai/syntheticToolResponses.js.map +1 -1
  95. package/dist/src/providers/openai-responses/OpenAIResponsesProvider.d.ts +89 -0
  96. package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js +451 -0
  97. package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js.map +1 -0
  98. package/dist/src/providers/openai-responses/index.d.ts +1 -0
  99. package/dist/src/providers/openai-responses/index.js +2 -0
  100. package/dist/src/providers/openai-responses/index.js.map +1 -0
  101. package/dist/src/providers/tokenizers/OpenAITokenizer.js +3 -3
  102. package/dist/src/providers/tokenizers/OpenAITokenizer.js.map +1 -1
  103. package/dist/src/providers/types.d.ts +1 -1
  104. package/dist/src/services/ClipboardService.d.ts +19 -0
  105. package/dist/src/services/ClipboardService.js +66 -0
  106. package/dist/src/services/ClipboardService.js.map +1 -0
  107. package/dist/src/services/history/ContentConverters.d.ts +43 -0
  108. package/dist/src/services/history/ContentConverters.js +325 -0
  109. package/dist/src/services/history/ContentConverters.js.map +1 -0
  110. package/dist/src/{providers/IMessage.d.ts → services/history/HistoryEvents.d.ts} +16 -22
  111. package/dist/src/{providers/IMessage.js → services/history/HistoryEvents.js} +1 -1
  112. package/dist/src/services/history/HistoryEvents.js.map +1 -0
  113. package/dist/src/services/history/HistoryService.d.ts +220 -0
  114. package/dist/src/services/history/HistoryService.js +673 -0
  115. package/dist/src/services/history/HistoryService.js.map +1 -0
  116. package/dist/src/services/history/IContent.d.ts +183 -0
  117. package/dist/src/services/history/IContent.js +104 -0
  118. package/dist/src/services/history/IContent.js.map +1 -0
  119. package/dist/src/services/index.d.ts +1 -0
  120. package/dist/src/services/index.js +1 -0
  121. package/dist/src/services/index.js.map +1 -1
  122. package/dist/src/settings/SettingsService.js.map +1 -1
  123. package/dist/src/telemetry/types.d.ts +16 -4
  124. package/dist/src/telemetry/types.js.map +1 -1
  125. package/dist/src/tools/IToolFormatter.d.ts +2 -2
  126. package/dist/src/tools/ToolFormatter.d.ts +42 -4
  127. package/dist/src/tools/ToolFormatter.js +151 -64
  128. package/dist/src/tools/ToolFormatter.js.map +1 -1
  129. package/dist/src/tools/doubleEscapeUtils.d.ts +57 -0
  130. package/dist/src/tools/doubleEscapeUtils.js +241 -0
  131. package/dist/src/tools/doubleEscapeUtils.js.map +1 -0
  132. package/dist/src/tools/read-file.d.ts +6 -1
  133. package/dist/src/tools/read-file.js +25 -11
  134. package/dist/src/tools/read-file.js.map +1 -1
  135. package/dist/src/tools/todo-schemas.d.ts +4 -4
  136. package/dist/src/tools/tool-registry.d.ts +8 -1
  137. package/dist/src/tools/tool-registry.js +79 -23
  138. package/dist/src/tools/tool-registry.js.map +1 -1
  139. package/dist/src/tools/tools.js +13 -0
  140. package/dist/src/tools/tools.js.map +1 -1
  141. package/dist/src/tools/write-file.d.ts +6 -1
  142. package/dist/src/tools/write-file.js +48 -26
  143. package/dist/src/tools/write-file.js.map +1 -1
  144. package/dist/src/types/modelParams.d.ts +12 -0
  145. package/dist/src/utils/bfsFileSearch.js +2 -6
  146. package/dist/src/utils/bfsFileSearch.js.map +1 -1
  147. package/dist/src/utils/schemaValidator.js +16 -1
  148. package/dist/src/utils/schemaValidator.js.map +1 -1
  149. package/package.json +8 -7
  150. package/dist/src/providers/IMessage.js.map +0 -1
  151. package/dist/src/providers/adapters/GeminiCompatibleWrapper.d.ts +0 -69
  152. package/dist/src/providers/adapters/GeminiCompatibleWrapper.js +0 -577
  153. package/dist/src/providers/adapters/GeminiCompatibleWrapper.js.map +0 -1
@@ -9,12 +9,17 @@ import { createUserContent, } from '@google/genai';
9
9
  import { retryWithBackoff } from '../utils/retry.js';
10
10
  import { isFunctionResponse } from '../utils/messageInspectors.js';
11
11
  import { AuthType } from './contentGenerator.js';
12
- import { estimateTokens } from '../utils/toolOutputLimiter.js';
12
+ import { HistoryService } from '../services/history/HistoryService.js';
13
+ import { ContentConverters } from '../services/history/ContentConverters.js';
14
+ // import { estimateTokens } from '../utils/toolOutputLimiter.js'; // Unused after retry stream refactor
13
15
  import { logApiRequest, logApiResponse, logApiError, } from '../telemetry/loggers.js';
14
16
  import { ApiErrorEvent, ApiRequestEvent, ApiResponseEvent, } from '../telemetry/types.js';
15
17
  import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
16
18
  import { hasCycleInSchema } from '../tools/tools.js';
17
19
  import { isStructuredError } from '../utils/quotaErrorDetection.js';
20
+ import { DebugLogger } from '../debug/index.js';
21
+ import { getCompressionPrompt } from './prompts.js';
22
+ import { COMPRESSION_TOKEN_THRESHOLD, COMPRESSION_PRESERVE_THRESHOLD, } from './compression-config.js';
18
23
  /**
19
24
  * Custom createUserContent function that properly handles function response arrays.
20
25
  * This fixes the issue where multiple function responses are incorrectly nested.
@@ -54,9 +59,6 @@ function createUserContentWithFunctionResponseFix(message) {
54
59
  }
55
60
  else if (Array.isArray(item)) {
56
61
  // Nested array case - flatten it
57
- if (process.env.DEBUG) {
58
- console.log('[DEBUG] createUserContentWithFunctionResponseFix - flattening nested array:', JSON.stringify(item, null, 2));
59
- }
60
62
  for (const subItem of item) {
61
63
  parts.push(subItem);
62
64
  }
@@ -82,6 +84,10 @@ function createUserContentWithFunctionResponseFix(message) {
82
84
  }
83
85
  return result;
84
86
  }
87
+ const INVALID_CONTENT_RETRY_OPTIONS = {
88
+ maxAttempts: 3, // 1 initial call + 2 retries
89
+ initialDelayMs: 500,
90
+ };
85
91
  /**
86
92
  * Returns true if the response is valid, false otherwise.
87
93
  */
@@ -155,14 +161,20 @@ function extractCuratedHistory(comprehensiveHistory) {
155
161
  if (isValid) {
156
162
  curatedHistory.push(...modelOutput);
157
163
  }
158
- else {
159
- // Remove the last user input when model content is invalid.
160
- curatedHistory.pop();
161
- }
162
164
  }
163
165
  }
164
166
  return curatedHistory;
165
167
  }
168
+ /**
169
+ * Custom error to signal that a stream completed without valid content,
170
+ * which should trigger a retry.
171
+ */
172
+ export class EmptyStreamError extends Error {
173
+ constructor(message) {
174
+ super(message);
175
+ this.name = 'EmptyStreamError';
176
+ }
177
+ }
166
178
  /**
167
179
  * Chat session that enables sending messages to the model with previous
168
180
  * conversation context.
@@ -172,18 +184,58 @@ function extractCuratedHistory(comprehensiveHistory) {
172
184
  */
173
185
  export class GeminiChat {
174
186
  config;
175
- contentGenerator;
176
187
  generationConfig;
177
- history;
178
188
  // A promise to represent the current state of the message being sent to the
179
189
  // model.
180
190
  sendPromise = Promise.resolve();
181
- constructor(config, contentGenerator, generationConfig = {}, history = []) {
191
+ // A promise to represent any ongoing compression operation
192
+ compressionPromise = null;
193
+ historyService;
194
+ logger = new DebugLogger('llxprt:gemini:chat');
195
+ // Cache the compression threshold to avoid recalculating
196
+ cachedCompressionThreshold = null;
197
+ constructor(config, contentGenerator, generationConfig = {}, initialHistory = [], historyService) {
182
198
  this.config = config;
183
- this.contentGenerator = contentGenerator;
184
199
  this.generationConfig = generationConfig;
185
- this.history = history;
186
- validateHistory(history);
200
+ validateHistory(initialHistory);
201
+ // Use provided HistoryService or create a new one
202
+ this.historyService = historyService || new HistoryService();
203
+ this.logger.debug('GeminiChat initialized:', {
204
+ model: this.config.getModel(),
205
+ initialHistoryLength: initialHistory.length,
206
+ hasHistoryService: !!historyService,
207
+ });
208
+ // Convert and add initial history if provided
209
+ if (initialHistory.length > 0) {
210
+ const currentModel = this.config.getModel();
211
+ this.logger.debug('Adding initial history to service:', {
212
+ count: initialHistory.length,
213
+ });
214
+ const idGen = this.historyService.getIdGeneratorCallback();
215
+ for (const content of initialHistory) {
216
+ const matcher = this.makePositionMatcher();
217
+ this.historyService.add(ContentConverters.toIContent(content, idGen, matcher), currentModel);
218
+ }
219
+ }
220
+ }
221
+ /**
222
+ * Create a position-based matcher for Gemini tool responses.
223
+ * It returns the next unmatched tool call from the current history.
224
+ */
225
+ makePositionMatcher() {
226
+ const queue = this.historyService
227
+ .findUnmatchedToolCalls()
228
+ .map((b) => ({ historyId: b.id, toolName: b.name }));
229
+ // Return undefined if there are no unmatched tool calls
230
+ if (queue.length === 0) {
231
+ return undefined;
232
+ }
233
+ // Return a function that always returns a valid value (never undefined)
234
+ return () => {
235
+ const result = queue.shift();
236
+ // If queue is empty, return a fallback value
237
+ return result || { historyId: '', toolName: undefined };
238
+ };
187
239
  }
188
240
  _getRequestTextFromContents(contents) {
189
241
  return JSON.stringify(contents);
@@ -239,6 +291,13 @@ export class GeminiChat {
239
291
  setSystemInstruction(sysInstr) {
240
292
  this.generationConfig.systemInstruction = sysInstr;
241
293
  }
294
+ /**
295
+ * Get the underlying HistoryService instance
296
+ * @returns The HistoryService managing conversation history
297
+ */
298
+ getHistoryService() {
299
+ return this.historyService;
300
+ }
242
301
  /**
243
302
  * Sends a message to the model and returns the response.
244
303
  *
@@ -261,24 +320,79 @@ export class GeminiChat {
261
320
  */
262
321
  async sendMessage(params, prompt_id) {
263
322
  await this.sendPromise;
323
+ // Check compression - first check if already compressing, then check if needed
324
+ if (this.compressionPromise) {
325
+ this.logger.debug('Waiting for ongoing compression to complete');
326
+ await this.compressionPromise;
327
+ }
328
+ else if (this.shouldCompress()) {
329
+ // Only check shouldCompress if not already compressing
330
+ this.logger.debug('Triggering compression before message send');
331
+ this.compressionPromise = this.performCompression(prompt_id);
332
+ await this.compressionPromise;
333
+ this.compressionPromise = null;
334
+ }
264
335
  const userContent = createUserContentWithFunctionResponseFix(params.message);
265
- const requestContents = this.getHistory(true).concat(userContent);
266
- this._logApiRequest(requestContents, this.config.getModel(), prompt_id);
336
+ // DO NOT add user content to history yet - use send-then-commit pattern
337
+ // Get the active provider
338
+ const provider = this.getActiveProvider();
339
+ if (!provider) {
340
+ throw new Error('No active provider configured');
341
+ }
342
+ // Check if provider supports IContent interface
343
+ if (!this.providerSupportsIContent(provider)) {
344
+ throw new Error(`Provider ${provider.name} does not support IContent interface`);
345
+ }
346
+ // Get curated history WITHOUT the new user message
347
+ const currentHistory = this.historyService.getCuratedForProvider();
348
+ // Convert user content to IContent
349
+ const idGen = this.historyService.getIdGeneratorCallback();
350
+ const matcher = this.makePositionMatcher();
351
+ const userIContent = ContentConverters.toIContent(userContent, idGen, matcher);
352
+ // Build request with history + new message
353
+ const iContents = [...currentHistory, userIContent];
354
+ this._logApiRequest(ContentConverters.toGeminiContents(iContents), this.config.getModel(), prompt_id);
267
355
  const startTime = Date.now();
268
356
  let response;
269
357
  try {
270
- const apiCall = () => {
358
+ const apiCall = async () => {
271
359
  const modelToUse = this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
272
360
  // Prevent Flash model calls immediately after quota error
273
361
  if (this.config.getQuotaErrorOccurred() &&
274
362
  modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
275
363
  throw new Error('Please submit a new query to continue with the Flash model.');
276
364
  }
277
- return this.contentGenerator.generateContent({
278
- model: modelToUse,
279
- contents: requestContents,
280
- config: { ...this.generationConfig, ...params.config },
281
- }, prompt_id);
365
+ // Get tools in the format the provider expects
366
+ const tools = this.generationConfig.tools;
367
+ // Debug log what tools we're passing to the provider
368
+ this.logger.debug(() => `[GeminiChat] Passing tools to provider.generateChatCompletion:`, {
369
+ hasTools: !!tools,
370
+ toolsLength: tools?.length,
371
+ toolsType: typeof tools,
372
+ isArray: Array.isArray(tools),
373
+ firstTool: tools?.[0],
374
+ toolNames: Array.isArray(tools)
375
+ ? tools.map((t) => {
376
+ const toolObj = t;
377
+ return (toolObj.functionDeclarations?.[0]?.name ||
378
+ toolObj.name ||
379
+ 'unknown');
380
+ })
381
+ : 'not-an-array',
382
+ providerName: provider.name,
383
+ });
384
+ // Call the provider directly with IContent
385
+ const streamResponse = provider.generateChatCompletion(iContents, tools);
386
+ // Collect all chunks from the stream
387
+ let lastResponse;
388
+ for await (const iContent of streamResponse) {
389
+ lastResponse = iContent;
390
+ }
391
+ if (!lastResponse) {
392
+ throw new Error('No response from provider');
393
+ }
394
+ // Convert the final IContent to GenerateContentResponse
395
+ return this.convertIContentToResponse(lastResponse);
282
396
  };
283
397
  response = await retryWithBackoff(apiCall, {
284
398
  shouldRetry: (error) => {
@@ -300,18 +414,49 @@ export class GeminiChat {
300
414
  await this._logApiResponse(durationMs, prompt_id, response.usageMetadata, JSON.stringify(response));
301
415
  this.sendPromise = (async () => {
302
416
  const outputContent = response.candidates?.[0]?.content;
303
- // Because the AFC input contains the entire curated chat history in
304
- // addition to the new user input, we need to truncate the AFC history
305
- // to deduplicate the existing chat history.
417
+ // Send-then-commit: Now that we have a successful response, add both user and model messages
418
+ const currentModel = this.config.getModel();
419
+ // Handle AFC history or regular history
306
420
  const fullAutomaticFunctionCallingHistory = response.automaticFunctionCallingHistory;
307
- const index = this.getHistory(true).length;
308
- let automaticFunctionCallingHistory = [];
309
- if (fullAutomaticFunctionCallingHistory != null) {
310
- automaticFunctionCallingHistory =
311
- fullAutomaticFunctionCallingHistory.slice(index) ?? [];
421
+ if (fullAutomaticFunctionCallingHistory &&
422
+ fullAutomaticFunctionCallingHistory.length > 0) {
423
+ // AFC case: Add the AFC history which includes the user input
424
+ const curatedHistory = this.historyService.getCurated();
425
+ const index = ContentConverters.toGeminiContents(curatedHistory).length;
426
+ const automaticFunctionCallingHistory = fullAutomaticFunctionCallingHistory.slice(index) ?? [];
427
+ for (const content of automaticFunctionCallingHistory) {
428
+ const idGen = this.historyService.getIdGeneratorCallback();
429
+ const matcher = this.makePositionMatcher();
430
+ this.historyService.add(ContentConverters.toIContent(content, idGen, matcher), currentModel);
431
+ }
432
+ }
433
+ else {
434
+ // Regular case: Add user content first
435
+ const idGen = this.historyService.getIdGeneratorCallback();
436
+ const matcher = this.makePositionMatcher();
437
+ this.historyService.add(ContentConverters.toIContent(userContent, idGen, matcher), currentModel);
438
+ }
439
+ // Add model response if we have one (but filter out pure thinking responses)
440
+ if (outputContent) {
441
+ // Check if this is pure thinking content that should be filtered
442
+ if (!this.isThoughtContent(outputContent)) {
443
+ // Not pure thinking, add it
444
+ const idGen = this.historyService.getIdGeneratorCallback();
445
+ this.historyService.add(ContentConverters.toIContent(outputContent, idGen), currentModel);
446
+ }
447
+ // If it's pure thinking content, don't add it to history
448
+ }
449
+ else if (response.candidates && response.candidates.length > 0) {
450
+ // We have candidates but no content - add empty model response
451
+ // This handles the case where the model returns empty content
452
+ if (!fullAutomaticFunctionCallingHistory ||
453
+ fullAutomaticFunctionCallingHistory.length === 0) {
454
+ const emptyModelContent = { role: 'model', parts: [] };
455
+ const idGen = this.historyService.getIdGeneratorCallback();
456
+ this.historyService.add(ContentConverters.toIContent(emptyModelContent, idGen), currentModel);
457
+ }
312
458
  }
313
- const modelOutput = outputContent ? [outputContent] : [];
314
- this.recordHistory(userContent, modelOutput, automaticFunctionCallingHistory);
459
+ // If no candidates at all, don't add anything (error case)
315
460
  })();
316
461
  await this.sendPromise.catch(() => {
317
462
  // Resets sendPromise to avoid subsequent calls failing
@@ -350,189 +495,170 @@ export class GeminiChat {
350
495
  * ```
351
496
  */
352
497
  async sendMessageStream(params, prompt_id) {
353
- if (process.env.DEBUG) {
354
- console.log('DEBUG [geminiChat]: ===== SEND MESSAGE STREAM START =====');
355
- console.log('DEBUG [geminiChat]: Model from config:', this.config.getModel());
356
- console.log('DEBUG [geminiChat]: Params:', JSON.stringify(params, null, 2));
357
- console.log('DEBUG [geminiChat]: Message type:', typeof params.message);
358
- console.log('DEBUG [geminiChat]: Message content:', JSON.stringify(params.message, null, 2));
359
- }
360
- if (process.env.DEBUG) {
361
- console.log('DEBUG: GeminiChat.sendMessageStream called');
362
- console.log('DEBUG: GeminiChat.sendMessageStream params:', JSON.stringify(params, null, 2));
363
- console.log('DEBUG: GeminiChat.sendMessageStream params.message type:', typeof params.message);
364
- console.log('DEBUG: GeminiChat.sendMessageStream params.message:', JSON.stringify(params.message, null, 2));
365
- }
498
+ this.logger.debug(() => 'DEBUG [geminiChat]: ===== SEND MESSAGE STREAM START =====');
499
+ this.logger.debug(() => `DEBUG [geminiChat]: Model from config: ${this.config.getModel()}`);
500
+ this.logger.debug(() => `DEBUG [geminiChat]: Params: ${JSON.stringify(params, null, 2)}`);
501
+ this.logger.debug(() => `DEBUG [geminiChat]: Message type: ${typeof params.message}`);
502
+ this.logger.debug(() => `DEBUG [geminiChat]: Message content: ${JSON.stringify(params.message, null, 2)}`);
503
+ this.logger.debug(() => 'DEBUG: GeminiChat.sendMessageStream called');
504
+ this.logger.debug(() => `DEBUG: GeminiChat.sendMessageStream params: ${JSON.stringify(params, null, 2)}`);
505
+ this.logger.debug(() => `DEBUG: GeminiChat.sendMessageStream params.message type: ${typeof params.message}`);
506
+ this.logger.debug(() => `DEBUG: GeminiChat.sendMessageStream params.message: ${JSON.stringify(params.message, null, 2)}`);
366
507
  await this.sendPromise;
367
- const userContent = createUserContentWithFunctionResponseFix(params.message);
368
- // Debug: Check if this is a function response submission
369
- if (Array.isArray(params.message)) {
370
- let functionResponseCount = 0;
371
- params.message.forEach((part) => {
372
- if (part && typeof part === 'object' && 'functionResponse' in part) {
373
- functionResponseCount++;
374
- }
375
- });
376
- if (functionResponseCount > 0) {
377
- if (process.env.DEBUG) {
378
- console.log(`[DEBUG geminiChat] Sending ${functionResponseCount} function response(s) in array`);
379
- }
380
- }
508
+ // Check compression - first check if already compressing, then check if needed
509
+ if (this.compressionPromise) {
510
+ this.logger.debug('Waiting for ongoing compression to complete');
511
+ await this.compressionPromise;
512
+ }
513
+ else if (this.shouldCompress()) {
514
+ // Only check shouldCompress if not already compressing
515
+ this.logger.debug('Triggering compression before message send in stream');
516
+ this.compressionPromise = this.performCompression(prompt_id);
517
+ await this.compressionPromise;
518
+ this.compressionPromise = null;
381
519
  }
382
- if (process.env.DEBUG) {
383
- console.log('DEBUG [geminiChat]: Created userContent:', JSON.stringify(userContent, null, 2));
384
- }
385
- if (process.env.DEBUG) {
386
- console.log('DEBUG: GeminiChat.sendMessageStream userContent:', JSON.stringify(userContent, null, 2));
387
- }
388
- const requestContents = this.getHistory(true).concat(userContent);
389
- // Apply max-prompt-tokens limit if configured
390
- const ephemeralSettings = this.config.getEphemeralSettings();
391
- const maxPromptTokens = ephemeralSettings['max-prompt-tokens'];
392
- if (maxPromptTokens) {
393
- // Estimate tokens in the full request
394
- const fullPromptText = JSON.stringify(requestContents);
395
- const estimatedTokens = estimateTokens(fullPromptText);
396
- if (estimatedTokens > maxPromptTokens) {
397
- console.warn(`WARNING: Prompt size (${estimatedTokens} tokens) exceeds max-prompt-tokens limit (${maxPromptTokens}). Trimming...`);
398
- // Add a warning message to the request that will be visible to the LLM
399
- const warningMessage = {
520
+ // Check if this is a paired tool call/response array
521
+ let userContent;
522
+ // Quick check for paired tool call/response
523
+ const messageArray = Array.isArray(params.message) ? params.message : null;
524
+ const isPairedToolResponse = messageArray &&
525
+ messageArray.length === 2 &&
526
+ messageArray[0] &&
527
+ typeof messageArray[0] === 'object' &&
528
+ 'functionCall' in messageArray[0] &&
529
+ messageArray[1] &&
530
+ typeof messageArray[1] === 'object' &&
531
+ 'functionResponse' in messageArray[1];
532
+ if (isPairedToolResponse && messageArray) {
533
+ // This is a paired tool call/response from the executor
534
+ // Create separate Content objects with correct roles
535
+ userContent = [
536
+ {
537
+ role: 'model',
538
+ parts: [messageArray[0]],
539
+ },
540
+ {
400
541
  role: 'user',
401
- parts: [
402
- {
403
- text: `WARNING: SYSTEM WARNING: The original prompt exceeded the ${maxPromptTokens} token limit (estimated ${estimatedTokens} tokens). Some conversation history and tool outputs have been truncated to fit. This may affect context continuity. Please be aware that some information from earlier in the conversation or from tool outputs may be missing.`,
404
- },
405
- ],
406
- };
407
- // Strategy: Keep the most recent messages and the current user message
408
- // Remove older messages and truncate tool outputs in the middle
409
- const trimmedContents = this.trimPromptContents(requestContents, maxPromptTokens);
410
- // Add the warning as the first message so LLM knows about the truncation
411
- trimmedContents.unshift(warningMessage);
412
- // Log the trimming action with more detail
413
- const trimmedTokens = estimateTokens(JSON.stringify(trimmedContents));
414
- console.log(`INFO: TRIMMED: Trimmed prompt from ${estimatedTokens} to ~${trimmedTokens} tokens`);
415
- // Count function calls in original vs trimmed
416
- let originalFunctionCalls = 0;
417
- let trimmedFunctionCalls = 0;
418
- requestContents.forEach((c) => c.parts?.forEach((p) => {
419
- if ('functionCall' in p)
420
- originalFunctionCalls++;
421
- }));
422
- trimmedContents.forEach((c) => c.parts?.forEach((p) => {
423
- if ('functionCall' in p)
424
- trimmedFunctionCalls++;
425
- }));
426
- if (originalFunctionCalls !== trimmedFunctionCalls) {
427
- console.warn(`WARNING: Trimming removed ${originalFunctionCalls - trimmedFunctionCalls} function calls (${originalFunctionCalls} -> ${trimmedFunctionCalls})`);
428
- }
429
- // Use trimmed contents instead
430
- requestContents.length = 0;
431
- requestContents.push(...trimmedContents);
432
- }
542
+ parts: [messageArray[1]],
543
+ },
544
+ ];
433
545
  }
434
- // Debug: Log the last few messages to see the function call/response pattern
435
- if (process.env.DEBUG && requestContents.length > 2) {
436
- const recentContents = requestContents.slice(-3);
437
- console.log('[DEBUG geminiChat] Recent conversation turns:');
438
- recentContents.forEach((content, idx) => {
439
- let summary = ` ${idx}: role=${content.role}, parts=${content.parts?.length || 0}`;
440
- content.parts?.forEach((part) => {
441
- if ('functionCall' in part && part.functionCall) {
442
- summary += ` [functionCall: ${part.functionCall.name}]`;
443
- }
444
- else if ('functionResponse' in part && part.functionResponse) {
445
- summary += ` [functionResponse: ${part.functionResponse.name}]`;
546
+ else {
547
+ userContent = createUserContentWithFunctionResponseFix(params.message);
548
+ }
549
+ // DO NOT add anything to history here - wait until after successful send!
550
+ // Tool responses will be handled in recordHistory after the model responds
551
+ let streamDoneResolver;
552
+ const streamDonePromise = new Promise((resolve) => {
553
+ streamDoneResolver = resolve;
554
+ });
555
+ this.sendPromise = streamDonePromise;
556
+ // DO NOT add user content to history yet - wait until successful send
557
+ // This is the send-then-commit pattern to avoid orphaned tool calls
558
+ return (async function* (instance) {
559
+ try {
560
+ let lastError = new Error('Request failed after all retries.');
561
+ for (let attempt = 0; attempt <= INVALID_CONTENT_RETRY_OPTIONS.maxAttempts; attempt++) {
562
+ try {
563
+ const stream = await instance.makeApiCallAndProcessStream(params, prompt_id, userContent);
564
+ for await (const chunk of stream) {
565
+ yield chunk;
566
+ }
567
+ lastError = null;
568
+ break;
446
569
  }
447
- else if ('text' in part && part.text) {
448
- summary += ` [text: ${part.text.substring(0, 50)}...]`;
570
+ catch (error) {
571
+ lastError = error;
572
+ const isContentError = error instanceof EmptyStreamError;
573
+ if (isContentError) {
574
+ // Check if we have more attempts left.
575
+ if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
576
+ await new Promise((res) => setTimeout(res, INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs *
577
+ (attempt + 1)));
578
+ continue;
579
+ }
580
+ }
581
+ break;
449
582
  }
450
- });
451
- console.log(summary);
452
- });
453
- }
454
- if (process.env.DEBUG) {
455
- console.log('DEBUG: GeminiChat.sendMessageStream requestContents:', JSON.stringify(requestContents, null, 2));
456
- }
457
- this._logApiRequest(requestContents, this.config.getModel(), prompt_id);
458
- const startTime = Date.now();
459
- try {
460
- const apiCall = () => {
461
- const modelToUse = this.config.getModel();
462
- const authType = this.config.getContentGeneratorConfig()?.authType;
463
- // Prevent Flash model calls immediately after quota error (only for Gemini providers)
464
- if (authType !== AuthType.USE_PROVIDER &&
465
- this.config.getQuotaErrorOccurred() &&
466
- modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
467
- throw new Error('Please submit a new query to continue with the Flash model.');
468
- }
469
- if (process.env.DEBUG) {
470
- console.log('DEBUG [geminiChat]: About to call generateContentStream with:');
471
- console.log('DEBUG [geminiChat]: - Model:', modelToUse);
472
- console.log('DEBUG [geminiChat]: - Contents:', JSON.stringify(requestContents, null, 2));
473
- console.log('DEBUG [geminiChat]: - Config:', JSON.stringify({ ...this.generationConfig, ...params.config }, null, 2));
474
- console.log('DEBUG [geminiChat]: - Tools in generationConfig:', JSON.stringify(this.generationConfig.tools, null, 2));
475
- console.log('DEBUG [geminiChat]: - Tools in params.config:', JSON.stringify(params.config?.tools, null, 2));
476
583
  }
477
- // Check if this is a model-specific issue
478
- const isFlashModel = modelToUse && modelToUse.includes('flash');
479
- if (process.env.DEBUG) {
480
- console.log('DEBUG [geminiChat]: - Is Flash model:', isFlashModel);
584
+ if (lastError) {
585
+ // With send-then-commit pattern, we don't add to history until success,
586
+ // so there's nothing to remove on failure
587
+ throw lastError;
481
588
  }
482
- // Extract systemInstruction from generationConfig if it exists
483
- const { systemInstruction, ...restGenerationConfig } = this.generationConfig;
484
- // Create properly typed request parameters
485
- const mergedConfig = {
486
- ...restGenerationConfig,
487
- ...params.config,
488
- };
489
- // Add systemInstruction to the config if it exists
490
- if (systemInstruction) {
491
- mergedConfig.systemInstruction = systemInstruction;
492
- }
493
- const requestParams = {
494
- model: modelToUse,
495
- contents: requestContents,
496
- config: mergedConfig,
497
- };
498
- return this.contentGenerator.generateContentStream(requestParams, prompt_id);
499
- };
500
- // Note: Retrying streams can be complex. If generateContentStream itself doesn't handle retries
501
- // for transient issues internally before yielding the async generator, this retry will re-initiate
502
- // the stream. For simple 429/500 errors on initial call, this is fine.
503
- // If errors occur mid-stream, this setup won't resume the stream; it will restart it.
504
- const streamResponse = await retryWithBackoff(apiCall, {
505
- shouldRetry: (error) => {
506
- // Check for known error messages and codes.
507
- if (error instanceof Error && error.message) {
508
- if (isSchemaDepthError(error.message))
509
- return false;
510
- if (error.message.includes('429'))
511
- return true;
512
- if (error.message.match(/5\d{2}/))
513
- return true;
514
- }
515
- return false; // Don't retry other errors by default
516
- },
517
- onPersistent429: async (authType, error) => await this.handleFlashFallback(authType, error),
518
- authType: this.config.getContentGeneratorConfig()?.authType,
519
- });
520
- // Resolve the internal tracking of send completion promise - `sendPromise`
521
- // for both success and failure response. The actual failure is still
522
- // propagated by the `await streamResponse`.
523
- this.sendPromise = Promise.resolve(streamResponse)
524
- .then(() => undefined)
525
- .catch(() => undefined);
526
- const result = this.processStreamResponse(streamResponse, userContent, startTime, prompt_id);
527
- return result;
589
+ }
590
+ finally {
591
+ streamDoneResolver();
592
+ }
593
+ })(this);
594
+ }
595
+ async makeApiCallAndProcessStream(_params, _prompt_id, userContent) {
596
+ // Get the active provider
597
+ const provider = this.getActiveProvider();
598
+ if (!provider) {
599
+ throw new Error('No active provider configured');
528
600
  }
529
- catch (error) {
530
- const durationMs = Date.now() - startTime;
531
- this._logApiError(durationMs, error, prompt_id);
532
- this.sendPromise = Promise.resolve();
533
- await this.maybeIncludeSchemaDepthContext(error);
534
- throw error;
601
+ // Check if provider supports IContent interface
602
+ if (!this.providerSupportsIContent(provider)) {
603
+ throw new Error(`Provider ${provider.name} does not support IContent interface`);
535
604
  }
605
+ const apiCall = async () => {
606
+ const modelToUse = this.config.getModel();
607
+ const authType = this.config.getContentGeneratorConfig()?.authType;
608
+ // Prevent Flash model calls immediately after quota error (only for Gemini providers)
609
+ if (authType !== AuthType.USE_PROVIDER &&
610
+ this.config.getQuotaErrorOccurred() &&
611
+ modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
612
+ throw new Error('Please submit a new query to continue with the Flash model.');
613
+ }
614
+ // Convert user content to IContent first so we can check if it's a tool response
615
+ const idGen = this.historyService.getIdGeneratorCallback();
616
+ const matcher = this.makePositionMatcher();
617
+ let requestContents;
618
+ if (Array.isArray(userContent)) {
619
+ // This is a paired tool call/response - convert each separately
620
+ const userIContents = userContent.map((content) => ContentConverters.toIContent(content, idGen, matcher));
621
+ // Get curated history WITHOUT the new user message (since we haven't added it yet)
622
+ const currentHistory = this.historyService.getCuratedForProvider();
623
+ // Build request with history + new messages (but don't commit to history yet)
624
+ requestContents = [...currentHistory, ...userIContents];
625
+ }
626
+ else {
627
+ const userIContent = ContentConverters.toIContent(userContent, idGen, matcher);
628
+ // Get curated history WITHOUT the new user message (since we haven't added it yet)
629
+ const currentHistory = this.historyService.getCuratedForProvider();
630
+ // Build request with history + new message (but don't commit to history yet)
631
+ requestContents = [...currentHistory, userIContent];
632
+ }
633
+ // DEBUG: Check for malformed entries
634
+ this.logger.debug(() => `[DEBUG] geminiChat IContent request (history + new message): ${JSON.stringify(requestContents, null, 2)}`);
635
+ // Get tools in the format the provider expects
636
+ const tools = this.generationConfig.tools;
637
+ // Call the provider directly with IContent
638
+ const streamResponse = provider.generateChatCompletion(requestContents, tools);
639
+ // Convert the IContent stream to GenerateContentResponse stream
640
+ return (async function* (instance) {
641
+ for await (const iContent of streamResponse) {
642
+ yield instance.convertIContentToResponse(iContent);
643
+ }
644
+ })(this);
645
+ };
646
+ const streamResponse = await retryWithBackoff(apiCall, {
647
+ shouldRetry: (error) => {
648
+ if (error instanceof Error && error.message) {
649
+ if (isSchemaDepthError(error.message))
650
+ return false;
651
+ if (error.message.includes('429'))
652
+ return true;
653
+ if (error.message.match(/5\d{2}/))
654
+ return true;
655
+ }
656
+ return false;
657
+ },
658
+ onPersistent429: async (authType, error) => await this.handleFlashFallback(authType, error),
659
+ authType: this.config.getContentGeneratorConfig()?.authType,
660
+ });
661
+ return this.processStreamResponse(streamResponse, userContent);
536
662
  }
537
663
  /**
538
664
  * Returns the chat history.
@@ -558,33 +684,220 @@ export class GeminiChat {
558
684
  * chat session.
559
685
  */
560
686
  getHistory(curated = false) {
561
- const history = curated
562
- ? extractCuratedHistory(this.history)
563
- : this.history;
687
+ // Get history from HistoryService in IContent format
688
+ const iContents = curated
689
+ ? this.historyService.getCurated()
690
+ : this.historyService.getAll();
691
+ // Convert to Gemini Content format
692
+ const contents = ContentConverters.toGeminiContents(iContents);
564
693
  // Deep copy the history to avoid mutating the history outside of the
565
694
  // chat session.
566
- return structuredClone(history);
695
+ return structuredClone(contents);
567
696
  }
568
697
  /**
569
698
  * Clears the chat history.
570
699
  */
571
700
  clearHistory() {
572
- this.history = [];
701
+ this.historyService.clear();
573
702
  }
574
703
  /**
575
704
  * Adds a new entry to the chat history.
576
- *
577
- * @param content - The content to add to the history.
578
705
  */
579
706
  addHistory(content) {
580
- this.history.push(content);
707
+ this.historyService.add(ContentConverters.toIContent(content), this.config.getModel());
581
708
  }
582
709
  setHistory(history) {
583
- this.history = history;
710
+ this.historyService.clear();
711
+ const currentModel = this.config.getModel();
712
+ for (const content of history) {
713
+ this.historyService.add(ContentConverters.toIContent(content), currentModel);
714
+ }
584
715
  }
585
716
  setTools(tools) {
586
717
  this.generationConfig.tools = tools;
587
718
  }
719
+ /**
720
+ * Check if compression is needed based on token count
721
+ */
722
+ shouldCompress() {
723
+ // Calculate compression threshold only if not cached
724
+ if (this.cachedCompressionThreshold === null) {
725
+ const threshold = this.config.getEphemeralSetting('compression-threshold') ?? COMPRESSION_TOKEN_THRESHOLD;
726
+ const contextLimit = this.config.getEphemeralSetting('context-limit') ?? 60000; // Default context limit
727
+ this.cachedCompressionThreshold = threshold * contextLimit;
728
+ this.logger.debug('Calculated compression threshold:', {
729
+ threshold,
730
+ contextLimit,
731
+ compressionThreshold: this.cachedCompressionThreshold,
732
+ });
733
+ }
734
+ const currentTokens = this.historyService.getTotalTokens();
735
+ const shouldCompress = currentTokens >= this.cachedCompressionThreshold;
736
+ if (shouldCompress) {
737
+ this.logger.debug('Compression needed:', {
738
+ currentTokens,
739
+ threshold: this.cachedCompressionThreshold,
740
+ });
741
+ }
742
+ return shouldCompress;
743
+ }
744
+ /**
745
+ * Perform compression of chat history
746
+ * Made public to allow manual compression triggering
747
+ */
748
+ async performCompression(prompt_id) {
749
+ this.logger.debug('Starting compression');
750
+ // Reset cached threshold after compression in case settings changed
751
+ this.cachedCompressionThreshold = null;
752
+ // Lock history service
753
+ this.historyService.startCompression();
754
+ try {
755
+ // Get compression split
756
+ const { toCompress, toKeep } = this.getCompressionSplit();
757
+ if (toCompress.length === 0) {
758
+ this.logger.debug('Nothing to compress');
759
+ return;
760
+ }
761
+ // Perform direct compression API call
762
+ const summary = await this.directCompressionCall(toCompress, prompt_id);
763
+ // Apply compression atomically
764
+ this.applyCompression(summary, toKeep);
765
+ this.logger.debug('Compression completed successfully');
766
+ }
767
+ catch (error) {
768
+ this.logger.error('Compression failed:', error);
769
+ throw error;
770
+ }
771
+ finally {
772
+ // Always unlock
773
+ this.historyService.endCompression();
774
+ }
775
+ }
776
+ /**
777
+ * Get the split point for compression
778
+ */
779
+ getCompressionSplit() {
780
+ const curated = this.historyService.getCurated();
781
+ // Calculate split point (keep last 30%)
782
+ const preserveThreshold = this.config.getEphemeralSetting('compression-preserve-threshold') ?? COMPRESSION_PRESERVE_THRESHOLD;
783
+ let splitIndex = Math.floor(curated.length * (1 - preserveThreshold));
784
+ // Adjust for tool call boundaries
785
+ splitIndex = this.adjustForToolCallBoundary(curated, splitIndex);
786
+ // Never compress if too few messages
787
+ if (splitIndex < 4) {
788
+ return { toCompress: [], toKeep: curated };
789
+ }
790
+ return {
791
+ toCompress: curated.slice(0, splitIndex),
792
+ toKeep: curated.slice(splitIndex),
793
+ };
794
+ }
795
+ /**
796
+ * Adjust compression boundary to not split tool call/response pairs
797
+ */
798
+ adjustForToolCallBoundary(history, index) {
799
+ // Don't split tool responses from their calls
800
+ while (index < history.length && history[index].speaker === 'tool') {
801
+ index++;
802
+ }
803
+ // Check if previous message has unmatched tool calls
804
+ if (index > 0) {
805
+ const prev = history[index - 1];
806
+ if (prev.speaker === 'ai') {
807
+ const toolCalls = prev.blocks.filter((b) => b.type === 'tool_call');
808
+ if (toolCalls.length > 0) {
809
+ // Check if there are matching tool responses in the kept portion
810
+ const keptHistory = history.slice(index);
811
+ const hasMatchingResponses = toolCalls.every((call) => {
812
+ const toolCall = call;
813
+ return keptHistory.some((msg) => msg.speaker === 'tool' &&
814
+ msg.blocks.some((b) => b.type === 'tool_response' &&
815
+ b.callId === toolCall.id));
816
+ });
817
+ if (!hasMatchingResponses) {
818
+ // Include the AI message with unmatched calls in the compression
819
+ return index - 1;
820
+ }
821
+ }
822
+ }
823
+ }
824
+ return index;
825
+ }
826
+ /**
827
+ * Direct API call for compression, bypassing normal message flow
828
+ */
829
+ async directCompressionCall(historyToCompress, _prompt_id) {
830
+ const provider = this.getActiveProvider();
831
+ if (!provider || !this.providerSupportsIContent(provider)) {
832
+ throw new Error('Provider does not support compression');
833
+ }
834
+ // Build compression request with system prompt and user history
835
+ const compressionRequest = [
836
+ // Add system instruction as the first message
837
+ {
838
+ speaker: 'human',
839
+ blocks: [
840
+ {
841
+ type: 'text',
842
+ text: getCompressionPrompt(),
843
+ },
844
+ ],
845
+ },
846
+ // Add the history to compress
847
+ ...historyToCompress,
848
+ // Add the trigger instruction
849
+ {
850
+ speaker: 'human',
851
+ blocks: [
852
+ {
853
+ type: 'text',
854
+ text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
855
+ },
856
+ ],
857
+ },
858
+ ];
859
+ // Direct provider call without tools for compression
860
+ const stream = provider.generateChatCompletion(compressionRequest, undefined);
861
+ // Collect response
862
+ let summary = '';
863
+ for await (const chunk of stream) {
864
+ if (chunk.blocks) {
865
+ for (const block of chunk.blocks) {
866
+ if (block.type === 'text') {
867
+ summary += block.text;
868
+ }
869
+ }
870
+ }
871
+ }
872
+ return summary;
873
+ }
874
+ /**
875
+ * Apply compression results to history
876
+ */
877
+ applyCompression(summary, toKeep) {
878
+ // Clear and rebuild history atomically
879
+ this.historyService.clear();
880
+ const currentModel = this.config.getModel();
881
+ // Add compressed summary as user message
882
+ this.historyService.add({
883
+ speaker: 'human',
884
+ blocks: [{ type: 'text', text: summary }],
885
+ }, currentModel);
886
+ // Add acknowledgment from AI
887
+ this.historyService.add({
888
+ speaker: 'ai',
889
+ blocks: [
890
+ {
891
+ type: 'text',
892
+ text: 'Got it. Thanks for the additional context!',
893
+ },
894
+ ],
895
+ }, currentModel);
896
+ // Add back the kept messages
897
+ for (const content of toKeep) {
898
+ this.historyService.add(content, currentModel);
899
+ }
900
+ }
588
901
  getFinalUsageMetadata(chunks) {
589
902
  const lastChunkWithMetadata = chunks
590
903
  .slice()
@@ -592,112 +905,134 @@ export class GeminiChat {
592
905
  .find((chunk) => chunk.usageMetadata);
593
906
  return lastChunkWithMetadata?.usageMetadata;
594
907
  }
595
- async *processStreamResponse(streamResponse, inputContent, startTime, prompt_id) {
596
- const outputContent = [];
597
- const chunks = [];
598
- let errorOccurred = false;
599
- try {
600
- for await (const chunk of streamResponse) {
601
- if (isValidResponse(chunk)) {
602
- chunks.push(chunk);
603
- const content = chunk.candidates?.[0]?.content;
604
- if (content !== undefined) {
605
- if (this.isThoughtContent(content)) {
606
- yield chunk;
607
- continue;
908
+ async *processStreamResponse(streamResponse, userInput) {
909
+ const modelResponseParts = [];
910
+ let hasReceivedValidContent = false;
911
+ let hasReceivedAnyChunk = false;
912
+ let invalidChunkCount = 0;
913
+ let totalChunkCount = 0;
914
+ for await (const chunk of streamResponse) {
915
+ hasReceivedAnyChunk = true;
916
+ totalChunkCount++;
917
+ if (isValidResponse(chunk)) {
918
+ const content = chunk.candidates?.[0]?.content;
919
+ if (content) {
920
+ // Check if this chunk has meaningful content (text or function calls)
921
+ if (content.parts && content.parts.length > 0) {
922
+ const hasMeaningfulContent = content.parts.some((part) => part.text ||
923
+ 'functionCall' in part ||
924
+ 'functionResponse' in part);
925
+ if (hasMeaningfulContent) {
926
+ hasReceivedValidContent = true;
608
927
  }
609
- outputContent.push(content);
928
+ }
929
+ // Filter out thought parts from being added to history.
930
+ if (!this.isThoughtContent(content) && content.parts) {
931
+ modelResponseParts.push(...content.parts);
610
932
  }
611
933
  }
612
- yield chunk;
613
934
  }
935
+ else {
936
+ invalidChunkCount++;
937
+ }
938
+ yield chunk; // Yield every chunk to the UI immediately.
614
939
  }
615
- catch (error) {
616
- errorOccurred = true;
617
- const durationMs = Date.now() - startTime;
618
- this._logApiError(durationMs, error, prompt_id);
619
- throw error;
620
- }
621
- if (!errorOccurred) {
622
- const durationMs = Date.now() - startTime;
623
- const allParts = [];
624
- for (const content of outputContent) {
625
- if (content.parts) {
626
- allParts.push(...content.parts);
627
- }
940
+ // Now that the stream is finished, make a decision.
941
+ // Only throw an error if:
942
+ // 1. We received no chunks at all, OR
943
+ // 2. We received chunks but NONE had valid content (all were invalid or empty)
944
+ // This allows models like Qwen to send empty chunks at the end of a stream
945
+ // as long as they sent valid content earlier.
946
+ if (!hasReceivedAnyChunk ||
947
+ (!hasReceivedValidContent && totalChunkCount > 0)) {
948
+ // Only throw if this looks like a genuinely empty/invalid stream
949
+ // Not just a stream that ended with some invalid chunks
950
+ if (invalidChunkCount === totalChunkCount ||
951
+ modelResponseParts.length === 0) {
952
+ throw new EmptyStreamError('Model stream was invalid or completed without valid content.');
628
953
  }
629
- await this._logApiResponse(durationMs, prompt_id, this.getFinalUsageMetadata(chunks), JSON.stringify(chunks));
630
954
  }
631
- this.recordHistory(inputContent, outputContent);
955
+ // Use recordHistory to correctly save the conversation turn.
956
+ const modelOutput = [
957
+ { role: 'model', parts: modelResponseParts },
958
+ ];
959
+ this.recordHistory(userInput, modelOutput);
632
960
  }
633
961
  recordHistory(userInput, modelOutput, automaticFunctionCallingHistory) {
634
- const nonThoughtModelOutput = modelOutput.filter((content) => !this.isThoughtContent(content));
635
- let outputContents = [];
636
- if (nonThoughtModelOutput.length > 0 &&
637
- nonThoughtModelOutput.every((content) => content.role !== undefined)) {
638
- outputContents = nonThoughtModelOutput;
639
- }
640
- else if (nonThoughtModelOutput.length === 0 && modelOutput.length > 0) {
641
- // This case handles when the model returns only a thought.
642
- // We don't want to add an empty model response in this case.
643
- }
644
- else {
645
- // When not a function response appends an empty content when model returns empty response, so that the
646
- // history is always alternating between user and model.
647
- // Workaround for: https://b.corp.google.com/issues/420354090
648
- if (!isFunctionResponse(userInput)) {
649
- outputContents.push({
650
- role: 'model',
651
- parts: [],
652
- });
653
- }
654
- }
962
+ const newHistoryEntries = [];
963
+ // Part 1: Handle the user's part of the turn.
655
964
  if (automaticFunctionCallingHistory &&
656
965
  automaticFunctionCallingHistory.length > 0) {
657
- this.history.push(...extractCuratedHistory(automaticFunctionCallingHistory));
966
+ const curatedAfc = extractCuratedHistory(automaticFunctionCallingHistory);
967
+ for (const content of curatedAfc) {
968
+ newHistoryEntries.push(ContentConverters.toIContent(content));
969
+ }
658
970
  }
659
971
  else {
660
- this.history.push(userInput);
661
- }
662
- // Consolidate adjacent model roles in outputContents
663
- const consolidatedOutputContents = [];
664
- for (const content of outputContents) {
665
- if (this.isThoughtContent(content)) {
666
- continue;
667
- }
668
- const lastContent = consolidatedOutputContents[consolidatedOutputContents.length - 1];
669
- if (this.isTextContent(lastContent) && this.isTextContent(content)) {
670
- // If both current and last are text, combine their text into the lastContent's first part
671
- // and append any other parts from the current content.
672
- lastContent.parts[0].text += content.parts[0].text || '';
673
- if (content.parts.length > 1) {
674
- lastContent.parts.push(...content.parts.slice(1));
972
+ // Handle both single Content and Content[] (for paired tool call/response)
973
+ const idGen = this.historyService.getIdGeneratorCallback();
974
+ const matcher = this.makePositionMatcher();
975
+ if (Array.isArray(userInput)) {
976
+ // This is a paired tool call/response from the executor
977
+ // Add each part to history
978
+ for (const content of userInput) {
979
+ const userIContent = ContentConverters.toIContent(content, idGen, matcher);
980
+ newHistoryEntries.push(userIContent);
675
981
  }
676
982
  }
677
983
  else {
678
- consolidatedOutputContents.push(content);
984
+ // Normal user message
985
+ const userIContent = ContentConverters.toIContent(userInput, idGen, matcher);
986
+ newHistoryEntries.push(userIContent);
679
987
  }
680
988
  }
681
- if (consolidatedOutputContents.length > 0) {
682
- const lastHistoryEntry = this.history[this.history.length - 1];
683
- const canMergeWithLastHistory = !automaticFunctionCallingHistory ||
684
- automaticFunctionCallingHistory.length === 0;
685
- if (canMergeWithLastHistory &&
686
- this.isTextContent(lastHistoryEntry) &&
687
- this.isTextContent(consolidatedOutputContents[0])) {
688
- // If both current and last are text, combine their text into the lastHistoryEntry's first part
689
- // and append any other parts from the current content.
690
- lastHistoryEntry.parts[0].text +=
691
- consolidatedOutputContents[0].parts[0].text || '';
692
- if (consolidatedOutputContents[0].parts.length > 1) {
693
- lastHistoryEntry.parts.push(...consolidatedOutputContents[0].parts.slice(1));
989
+ // Part 2: Handle the model's part of the turn, filtering out thoughts.
990
+ const nonThoughtModelOutput = modelOutput.filter((content) => !this.isThoughtContent(content));
991
+ let outputContents = [];
992
+ if (nonThoughtModelOutput.length > 0) {
993
+ outputContents = nonThoughtModelOutput;
994
+ }
995
+ else if (modelOutput.length === 0 &&
996
+ !Array.isArray(userInput) &&
997
+ !isFunctionResponse(userInput) &&
998
+ !automaticFunctionCallingHistory) {
999
+ // Add an empty model response if the model truly returned nothing.
1000
+ outputContents.push({ role: 'model', parts: [] });
1001
+ }
1002
+ // Part 3: Consolidate the parts of this turn's model response.
1003
+ const consolidatedOutputContents = [];
1004
+ if (outputContents.length > 0) {
1005
+ for (const content of outputContents) {
1006
+ const lastContent = consolidatedOutputContents[consolidatedOutputContents.length - 1];
1007
+ if (this.hasTextContent(lastContent) && this.hasTextContent(content)) {
1008
+ lastContent.parts[0].text += content.parts[0].text || '';
1009
+ if (content.parts.length > 1) {
1010
+ lastContent.parts.push(...content.parts.slice(1));
1011
+ }
1012
+ }
1013
+ else {
1014
+ consolidatedOutputContents.push(content);
694
1015
  }
695
- consolidatedOutputContents.shift(); // Remove the first element as it's merged
696
1016
  }
697
- this.history.push(...consolidatedOutputContents);
1017
+ }
1018
+ // Part 4: Add the new turn (user and model parts) to the history service.
1019
+ const currentModel = this.config.getModel();
1020
+ for (const entry of newHistoryEntries) {
1021
+ this.historyService.add(entry, currentModel);
1022
+ }
1023
+ for (const content of consolidatedOutputContents) {
1024
+ // Check if this contains tool calls
1025
+ const hasToolCalls = content.parts?.some((part) => part && typeof part === 'object' && 'functionCall' in part);
1026
+ if (!hasToolCalls) {
1027
+ // Only add non-tool-call responses to history immediately
1028
+ // Tool calls will be added when the executor returns with the response
1029
+ this.historyService.add(ContentConverters.toIContent(content), currentModel);
1030
+ }
1031
+ // Tool calls are NOT added here - they'll come back from the executor
1032
+ // along with their responses and be added together
698
1033
  }
699
1034
  }
700
- isTextContent(content) {
1035
+ hasTextContent(content) {
701
1036
  return !!(content &&
702
1037
  content.role === 'model' &&
703
1038
  content.parts &&
@@ -717,120 +1052,137 @@ export class GeminiChat {
717
1052
  * Trim prompt contents to fit within token limit
718
1053
  * Strategy: Keep the most recent user message, trim older history and tool outputs
719
1054
  */
720
- trimPromptContents(contents, maxTokens) {
721
- if (contents.length === 0)
722
- return contents;
723
- // Always keep the last message (current user input)
724
- const lastMessage = contents[contents.length - 1];
725
- const result = [];
726
- // Reserve tokens for the last message and warning
727
- const lastMessageTokens = estimateTokens(JSON.stringify(lastMessage));
728
- const warningTokens = 200; // Reserve for warning message
729
- let remainingTokens = maxTokens - lastMessageTokens - warningTokens;
730
- if (remainingTokens <= 0) {
731
- // Even the last message is too big, truncate it
732
- return [this.truncateContent(lastMessage, maxTokens - warningTokens)];
733
- }
734
- // Add messages from most recent to oldest, stopping when we hit the limit
735
- for (let i = contents.length - 2; i >= 0; i--) {
736
- const content = contents[i];
737
- const contentTokens = estimateTokens(JSON.stringify(content));
738
- if (contentTokens <= remainingTokens) {
739
- result.unshift(content);
740
- remainingTokens -= contentTokens;
741
- }
742
- else if (remainingTokens > 100) {
743
- // Try to truncate this content to fit
744
- const truncated = this.truncateContent(content, remainingTokens);
745
- // Only add if we actually got some content back
746
- if (truncated.parts && truncated.parts.length > 0) {
747
- result.unshift(truncated);
748
- }
749
- break;
750
- }
751
- else {
752
- // No room left, stop
753
- break;
754
- }
755
- }
756
- // Add the last message
757
- result.push(lastMessage);
758
- return result;
759
- }
1055
+ // private _trimPromptContents(
1056
+ // contents: Content[],
1057
+ // maxTokens: number,
1058
+ // ): Content[] {
1059
+ // if (contents.length === 0) return contents;
1060
+ //
1061
+ // // Always keep the last message (current user input)
1062
+ // const lastMessage = contents[contents.length - 1];
1063
+ // const result: Content[] = [];
1064
+ //
1065
+ // // Reserve tokens for the last message and warning
1066
+ // const lastMessageTokens = estimateTokens(JSON.stringify(lastMessage));
1067
+ // const warningTokens = 200; // Reserve for warning message
1068
+ // let remainingTokens = maxTokens - lastMessageTokens - warningTokens;
1069
+ //
1070
+ // if (remainingTokens <= 0) {
1071
+ // // Even the last message is too big, truncate it
1072
+ // return [this._truncateContent(lastMessage, maxTokens - warningTokens)];
1073
+ // }
1074
+ //
1075
+ // // Add messages from most recent to oldest, stopping when we hit the limit
1076
+ // for (let i = contents.length - 2; i >= 0; i--) {
1077
+ // const content = contents[i];
1078
+ // const contentTokens = estimateTokens(JSON.stringify(content));
1079
+ //
1080
+ // if (contentTokens <= remainingTokens) {
1081
+ // result.unshift(content);
1082
+ // remainingTokens -= contentTokens;
1083
+ // } else if (remainingTokens > 100) {
1084
+ // // Try to truncate this content to fit
1085
+ // const truncated = this._truncateContent(content, remainingTokens);
1086
+ // // Only add if we actually got some content back
1087
+ // if (truncated.parts && truncated.parts.length > 0) {
1088
+ // result.unshift(truncated);
1089
+ // }
1090
+ // break;
1091
+ // } else {
1092
+ // // No room left, stop
1093
+ // break;
1094
+ // }
1095
+ // }
1096
+ //
1097
+ // // Add the last message
1098
+ // result.push(lastMessage);
1099
+ //
1100
+ // return result;
1101
+ // }
1102
+ //
760
1103
  /**
761
1104
  * Truncate a single content to fit within token limit
762
1105
  */
763
- truncateContent(content, maxTokens) {
764
- if (!content.parts || content.parts.length === 0) {
765
- return content;
766
- }
767
- const truncatedParts = [];
768
- let currentTokens = 0;
769
- for (const part of content.parts) {
770
- if ('text' in part && part.text) {
771
- const partTokens = estimateTokens(part.text);
772
- if (currentTokens + partTokens <= maxTokens) {
773
- truncatedParts.push(part);
774
- currentTokens += partTokens;
775
- }
776
- else {
777
- // Truncate this part
778
- const remainingTokens = maxTokens - currentTokens;
779
- if (remainingTokens > 10) {
780
- const remainingChars = remainingTokens * 4;
781
- truncatedParts.push({
782
- text: part.text.substring(0, remainingChars) +
783
- '\n[...content truncated due to token limit...]',
784
- });
785
- }
786
- break;
787
- }
788
- }
789
- else {
790
- // Non-text parts (function calls, responses, etc) - NEVER truncate these
791
- // Either include them fully or skip them entirely to avoid breaking JSON
792
- const partTokens = estimateTokens(JSON.stringify(part));
793
- if (currentTokens + partTokens <= maxTokens) {
794
- truncatedParts.push(part);
795
- currentTokens += partTokens;
796
- }
797
- else {
798
- // Skip this part entirely - DO NOT truncate function calls/responses
799
- // Log what we're skipping for debugging
800
- if (process.env.DEBUG || process.env.VERBOSE) {
801
- let skipInfo = 'unknown part';
802
- if ('functionCall' in part) {
803
- const funcPart = part;
804
- skipInfo = `functionCall: ${funcPart.functionCall?.name || 'unnamed'}`;
805
- }
806
- else if ('functionResponse' in part) {
807
- const respPart = part;
808
- skipInfo = `functionResponse: ${respPart.functionResponse?.name || 'unnamed'}`;
809
- }
810
- console.warn(`INFO: Skipping ${skipInfo} due to token limit (needs ${partTokens} tokens, only ${maxTokens - currentTokens} available)`);
811
- }
812
- // Add a marker that content was omitted
813
- if (truncatedParts.length > 0 &&
814
- !truncatedParts.some((p) => 'text' in p &&
815
- p.text?.includes('[...function calls omitted due to token limit...]'))) {
816
- truncatedParts.push({
817
- text: '[...function calls omitted due to token limit...]',
818
- });
819
- }
820
- break;
821
- }
822
- }
823
- }
824
- return {
825
- role: content.role,
826
- parts: truncatedParts,
827
- };
828
- }
1106
+ // private _truncateContent(content: Content, maxTokens: number): Content {
1107
+ // if (!content.parts || content.parts.length === 0) {
1108
+ // return content;
1109
+ // }
1110
+ //
1111
+ // const truncatedParts: Part[] = [];
1112
+ // let currentTokens = 0;
1113
+ //
1114
+ // for (const part of content.parts) {
1115
+ // if ('text' in part && part.text) {
1116
+ // const partTokens = estimateTokens(part.text);
1117
+ // if (currentTokens + partTokens <= maxTokens) {
1118
+ // truncatedParts.push(part);
1119
+ // currentTokens += partTokens;
1120
+ // } else {
1121
+ // // Truncate this part
1122
+ // const remainingTokens = maxTokens - currentTokens;
1123
+ // if (remainingTokens > 10) {
1124
+ // const remainingChars = remainingTokens * 4;
1125
+ // truncatedParts.push({
1126
+ // text:
1127
+ // part.text.substring(0, remainingChars) +
1128
+ // '\n[...content truncated due to token limit...]',
1129
+ // });
1130
+ // }
1131
+ // break;
1132
+ // }
1133
+ // } else {
1134
+ // // Non-text parts (function calls, responses, etc) - NEVER truncate these
1135
+ // // Either include them fully or skip them entirely to avoid breaking JSON
1136
+ // const partTokens = estimateTokens(JSON.stringify(part));
1137
+ // if (currentTokens + partTokens <= maxTokens) {
1138
+ // truncatedParts.push(part);
1139
+ // currentTokens += partTokens;
1140
+ // } else {
1141
+ // // Skip this part entirely - DO NOT truncate function calls/responses
1142
+ // // Log what we're skipping for debugging
1143
+ // if (process.env.DEBUG || process.env.VERBOSE) {
1144
+ // let skipInfo = 'unknown part';
1145
+ // if ('functionCall' in part) {
1146
+ // const funcPart = part as { functionCall?: { name?: string } };
1147
+ // skipInfo = `functionCall: ${funcPart.functionCall?.name || 'unnamed'}`;
1148
+ // } else if ('functionResponse' in part) {
1149
+ // const respPart = part as { functionResponse?: { name?: string } };
1150
+ // skipInfo = `functionResponse: ${respPart.functionResponse?.name || 'unnamed'}`;
1151
+ // }
1152
+ // console.warn(
1153
+ // `INFO: Skipping ${skipInfo} due to token limit (needs ${partTokens} tokens, only ${maxTokens - currentTokens} available)`,
1154
+ // );
1155
+ // }
1156
+ // // Add a marker that content was omitted
1157
+ // if (
1158
+ // truncatedParts.length > 0 &&
1159
+ // !truncatedParts.some(
1160
+ // (p) =>
1161
+ // 'text' in p &&
1162
+ // p.text?.includes(
1163
+ // '[...function calls omitted due to token limit...]',
1164
+ // ),
1165
+ // )
1166
+ // ) {
1167
+ // truncatedParts.push({
1168
+ // text: '[...function calls omitted due to token limit...]',
1169
+ // });
1170
+ // }
1171
+ // break;
1172
+ // }
1173
+ // }
1174
+ // }
1175
+ //
1176
+ // return {
1177
+ // role: content.role,
1178
+ // parts: truncatedParts,
1179
+ // };
1180
+ // }
829
1181
  async maybeIncludeSchemaDepthContext(error) {
830
1182
  // Check for potentially problematic cyclic tools with cyclic schemas
831
1183
  // and include a recommendation to remove potentially problematic tools.
832
1184
  if (isStructuredError(error) && isSchemaDepthError(error.message)) {
833
- const tools = (await this.config.getToolRegistry()).getAllTools();
1185
+ const tools = this.config.getToolRegistry().getAllTools();
834
1186
  const cyclicSchemaTools = [];
835
1187
  for (const tool of tools) {
836
1188
  if ((tool.schema.parametersJsonSchema &&
@@ -847,6 +1199,189 @@ export class GeminiChat {
847
1199
  }
848
1200
  }
849
1201
  }
1202
+ /**
1203
+ * Convert PartListUnion (user input) to IContent format for provider/history
1204
+ */
1205
+ convertPartListUnionToIContent(input) {
1206
+ const blocks = [];
1207
+ if (typeof input === 'string') {
1208
+ // Simple string input from user
1209
+ return {
1210
+ speaker: 'human',
1211
+ blocks: [{ type: 'text', text: input }],
1212
+ };
1213
+ }
1214
+ // Handle Part or Part[]
1215
+ const parts = Array.isArray(input) ? input : [input];
1216
+ // Check if all parts are function responses (tool responses)
1217
+ const allFunctionResponses = parts.every((part) => part && typeof part === 'object' && 'functionResponse' in part);
1218
+ if (allFunctionResponses) {
1219
+ // Tool responses - speaker is 'tool'
1220
+ for (const part of parts) {
1221
+ if (typeof part === 'object' &&
1222
+ 'functionResponse' in part &&
1223
+ part.functionResponse) {
1224
+ blocks.push({
1225
+ type: 'tool_response',
1226
+ callId: part.functionResponse.id || '',
1227
+ toolName: part.functionResponse.name || '',
1228
+ result: part.functionResponse.response || {},
1229
+ error: undefined,
1230
+ });
1231
+ }
1232
+ }
1233
+ return {
1234
+ speaker: 'tool',
1235
+ blocks,
1236
+ };
1237
+ }
1238
+ // Mixed content or function calls - must be from AI
1239
+ let hasAIContent = false;
1240
+ for (const part of parts) {
1241
+ if (typeof part === 'string') {
1242
+ blocks.push({ type: 'text', text: part });
1243
+ }
1244
+ else if ('text' in part && part.text !== undefined) {
1245
+ blocks.push({ type: 'text', text: part.text });
1246
+ }
1247
+ else if ('functionCall' in part && part.functionCall) {
1248
+ hasAIContent = true; // Function calls only come from AI
1249
+ blocks.push({
1250
+ type: 'tool_call',
1251
+ id: part.functionCall.id || '',
1252
+ name: part.functionCall.name || '',
1253
+ parameters: part.functionCall.args || {},
1254
+ });
1255
+ }
1256
+ else if ('functionResponse' in part && part.functionResponse) {
1257
+ // Single function response in mixed content
1258
+ blocks.push({
1259
+ type: 'tool_response',
1260
+ callId: part.functionResponse.id || '',
1261
+ toolName: part.functionResponse.name || '',
1262
+ result: part.functionResponse.response || {},
1263
+ error: undefined,
1264
+ });
1265
+ }
1266
+ }
1267
+ // If we have function calls, it's AI content; otherwise assume human
1268
+ return {
1269
+ speaker: hasAIContent ? 'ai' : 'human',
1270
+ blocks,
1271
+ };
1272
+ }
1273
+ /**
1274
+ * Convert IContent (from provider) to GenerateContentResponse for SDK compatibility
1275
+ */
1276
+ convertIContentToResponse(input) {
1277
+ // Convert IContent blocks to Gemini Parts
1278
+ const parts = [];
1279
+ for (const block of input.blocks) {
1280
+ switch (block.type) {
1281
+ case 'text':
1282
+ parts.push({ text: block.text });
1283
+ break;
1284
+ case 'tool_call': {
1285
+ const toolCall = block;
1286
+ parts.push({
1287
+ functionCall: {
1288
+ id: toolCall.id,
1289
+ name: toolCall.name,
1290
+ args: toolCall.parameters,
1291
+ },
1292
+ });
1293
+ break;
1294
+ }
1295
+ case 'tool_response': {
1296
+ const toolResponse = block;
1297
+ parts.push({
1298
+ functionResponse: {
1299
+ id: toolResponse.callId,
1300
+ name: toolResponse.toolName,
1301
+ response: toolResponse.result,
1302
+ },
1303
+ });
1304
+ break;
1305
+ }
1306
+ case 'thinking':
1307
+ // Include thinking blocks as thought parts
1308
+ parts.push({
1309
+ thought: true,
1310
+ text: block.thought,
1311
+ });
1312
+ break;
1313
+ default:
1314
+ // Skip unsupported block types
1315
+ break;
1316
+ }
1317
+ }
1318
+ // Build the response structure
1319
+ const response = {
1320
+ candidates: [
1321
+ {
1322
+ content: {
1323
+ role: 'model',
1324
+ parts,
1325
+ },
1326
+ },
1327
+ ],
1328
+ // These are required properties that must be present
1329
+ get text() {
1330
+ return parts.find((p) => 'text' in p)?.text || '';
1331
+ },
1332
+ functionCalls: parts
1333
+ .filter((p) => 'functionCall' in p)
1334
+ .map((p) => p.functionCall),
1335
+ executableCode: undefined,
1336
+ codeExecutionResult: undefined,
1337
+ // data property will be added below
1338
+ };
1339
+ // Add data property that returns self-reference
1340
+ // Make it non-enumerable to avoid circular reference in JSON.stringify
1341
+ Object.defineProperty(response, 'data', {
1342
+ get() {
1343
+ return response;
1344
+ },
1345
+ enumerable: false, // Changed from true to false
1346
+ configurable: true,
1347
+ });
1348
+ // Add usage metadata if present
1349
+ if (input.metadata?.usage) {
1350
+ response.usageMetadata = {
1351
+ promptTokenCount: input.metadata.usage.promptTokens || 0,
1352
+ candidatesTokenCount: input.metadata.usage.completionTokens || 0,
1353
+ totalTokenCount: input.metadata.usage.totalTokens || 0,
1354
+ };
1355
+ }
1356
+ return response;
1357
+ }
1358
+ /**
1359
+ * Get the active provider from the ProviderManager via Config
1360
+ */
1361
+ getActiveProvider() {
1362
+ const providerManager = this.config.getProviderManager();
1363
+ if (!providerManager) {
1364
+ return undefined;
1365
+ }
1366
+ try {
1367
+ return providerManager.getActiveProvider();
1368
+ }
1369
+ catch {
1370
+ // No active provider set
1371
+ return undefined;
1372
+ }
1373
+ }
1374
+ /**
1375
+ * Check if a provider supports the IContent interface
1376
+ */
1377
+ providerSupportsIContent(provider) {
1378
+ if (!provider) {
1379
+ return false;
1380
+ }
1381
+ // Check if the provider has the IContent method
1382
+ return (typeof provider
1383
+ .generateChatCompletion === 'function');
1384
+ }
850
1385
  }
851
1386
  /** Visible for Testing */
852
1387
  export function isSchemaDepthError(errorMessage) {