@vybestack/llxprt-code-core 0.1.23-nightly.250905.97906524 → 0.2.2-nightly.250908.fb8099b7

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 (118) hide show
  1. package/dist/src/adapters/IStreamAdapter.d.ts +3 -3
  2. package/dist/src/auth/precedence.d.ts +1 -1
  3. package/dist/src/auth/precedence.js +9 -4
  4. package/dist/src/auth/precedence.js.map +1 -1
  5. package/dist/src/auth/types.d.ts +4 -4
  6. package/dist/src/code_assist/codeAssist.js +8 -6
  7. package/dist/src/code_assist/codeAssist.js.map +1 -1
  8. package/dist/src/code_assist/setup.js +9 -7
  9. package/dist/src/code_assist/setup.js.map +1 -1
  10. package/dist/src/config/index.d.ts +7 -0
  11. package/dist/src/config/index.js +8 -0
  12. package/dist/src/config/index.js.map +1 -0
  13. package/dist/src/core/client.d.ts +9 -21
  14. package/dist/src/core/client.js +55 -156
  15. package/dist/src/core/client.js.map +1 -1
  16. package/dist/src/core/compression-config.d.ts +1 -1
  17. package/dist/src/core/compression-config.js +4 -5
  18. package/dist/src/core/compression-config.js.map +1 -1
  19. package/dist/src/core/coreToolScheduler.js +50 -15
  20. package/dist/src/core/coreToolScheduler.js.map +1 -1
  21. package/dist/src/core/geminiChat.d.ts +51 -2
  22. package/dist/src/core/geminiChat.js +616 -106
  23. package/dist/src/core/geminiChat.js.map +1 -1
  24. package/dist/src/core/nonInteractiveToolExecutor.js +70 -19
  25. package/dist/src/core/nonInteractiveToolExecutor.js.map +1 -1
  26. package/dist/src/core/prompts.js +34 -26
  27. package/dist/src/core/prompts.js.map +1 -1
  28. package/dist/src/core/turn.d.ts +1 -0
  29. package/dist/src/core/turn.js +8 -6
  30. package/dist/src/core/turn.js.map +1 -1
  31. package/dist/src/index.d.ts +1 -2
  32. package/dist/src/index.js +2 -2
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/prompt-config/TemplateEngine.js +17 -0
  35. package/dist/src/prompt-config/TemplateEngine.js.map +1 -1
  36. package/dist/src/prompt-config/defaults/core-defaults.js +39 -32
  37. package/dist/src/prompt-config/defaults/core-defaults.js.map +1 -1
  38. package/dist/src/prompt-config/defaults/core.md +2 -0
  39. package/dist/src/prompt-config/defaults/provider-defaults.js +34 -27
  40. package/dist/src/prompt-config/defaults/provider-defaults.js.map +1 -1
  41. package/dist/src/prompt-config/defaults/providers/gemini/core.md +229 -43
  42. package/dist/src/prompt-config/defaults/providers/gemini/models/gemini-2.5-flash/core.md +12 -0
  43. package/dist/src/prompt-config/defaults/providers/gemini/models/gemini-2.5-flash/gemini-2-5-flash/core.md +12 -0
  44. package/dist/src/prompt-config/types.d.ts +2 -0
  45. package/dist/src/providers/BaseProvider.d.ts +32 -6
  46. package/dist/src/providers/BaseProvider.js +79 -22
  47. package/dist/src/providers/BaseProvider.js.map +1 -1
  48. package/dist/src/providers/IProvider.d.ts +9 -3
  49. package/dist/src/providers/LoggingProviderWrapper.d.ts +10 -3
  50. package/dist/src/providers/LoggingProviderWrapper.js +33 -27
  51. package/dist/src/providers/LoggingProviderWrapper.js.map +1 -1
  52. package/dist/src/providers/ProviderContentGenerator.d.ts +2 -2
  53. package/dist/src/providers/ProviderContentGenerator.js +9 -6
  54. package/dist/src/providers/ProviderContentGenerator.js.map +1 -1
  55. package/dist/src/providers/anthropic/AnthropicProvider.d.ts +27 -21
  56. package/dist/src/providers/anthropic/AnthropicProvider.js +473 -472
  57. package/dist/src/providers/anthropic/AnthropicProvider.js.map +1 -1
  58. package/dist/src/providers/gemini/GeminiProvider.d.ts +14 -9
  59. package/dist/src/providers/gemini/GeminiProvider.js +202 -486
  60. package/dist/src/providers/gemini/GeminiProvider.js.map +1 -1
  61. package/dist/src/providers/openai/ConversationCache.d.ts +3 -3
  62. package/dist/src/providers/openai/IChatGenerateParams.d.ts +9 -4
  63. package/dist/src/providers/openai/OpenAIProvider.d.ts +44 -115
  64. package/dist/src/providers/openai/OpenAIProvider.js +535 -948
  65. package/dist/src/providers/openai/OpenAIProvider.js.map +1 -1
  66. package/dist/src/providers/openai/buildResponsesRequest.d.ts +3 -3
  67. package/dist/src/providers/openai/buildResponsesRequest.js +67 -37
  68. package/dist/src/providers/openai/buildResponsesRequest.js.map +1 -1
  69. package/dist/src/providers/openai/estimateRemoteTokens.d.ts +2 -2
  70. package/dist/src/providers/openai/estimateRemoteTokens.js +21 -8
  71. package/dist/src/providers/openai/estimateRemoteTokens.js.map +1 -1
  72. package/dist/src/providers/openai/parseResponsesStream.d.ts +6 -2
  73. package/dist/src/providers/openai/parseResponsesStream.js +99 -391
  74. package/dist/src/providers/openai/parseResponsesStream.js.map +1 -1
  75. package/dist/src/providers/openai/syntheticToolResponses.d.ts +5 -5
  76. package/dist/src/providers/openai/syntheticToolResponses.js +102 -91
  77. package/dist/src/providers/openai/syntheticToolResponses.js.map +1 -1
  78. package/dist/src/providers/openai-responses/OpenAIResponsesProvider.d.ts +18 -20
  79. package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js +250 -239
  80. package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js.map +1 -1
  81. package/dist/src/providers/tokenizers/OpenAITokenizer.js +3 -3
  82. package/dist/src/providers/tokenizers/OpenAITokenizer.js.map +1 -1
  83. package/dist/src/providers/types.d.ts +1 -1
  84. package/dist/src/services/history/ContentConverters.d.ts +6 -1
  85. package/dist/src/services/history/ContentConverters.js +155 -18
  86. package/dist/src/services/history/ContentConverters.js.map +1 -1
  87. package/dist/src/services/history/HistoryService.d.ts +52 -0
  88. package/dist/src/services/history/HistoryService.js +245 -93
  89. package/dist/src/services/history/HistoryService.js.map +1 -1
  90. package/dist/src/services/history/IContent.d.ts +4 -0
  91. package/dist/src/services/history/IContent.js.map +1 -1
  92. package/dist/src/telemetry/types.d.ts +16 -4
  93. package/dist/src/telemetry/types.js.map +1 -1
  94. package/dist/src/tools/IToolFormatter.d.ts +2 -2
  95. package/dist/src/tools/ToolFormatter.d.ts +42 -4
  96. package/dist/src/tools/ToolFormatter.js +159 -37
  97. package/dist/src/tools/ToolFormatter.js.map +1 -1
  98. package/dist/src/tools/doubleEscapeUtils.d.ts +57 -0
  99. package/dist/src/tools/doubleEscapeUtils.js +241 -0
  100. package/dist/src/tools/doubleEscapeUtils.js.map +1 -0
  101. package/dist/src/tools/read-file.js +5 -2
  102. package/dist/src/tools/read-file.js.map +1 -1
  103. package/dist/src/tools/todo-schemas.d.ts +4 -4
  104. package/dist/src/tools/write-file.js +5 -2
  105. package/dist/src/tools/write-file.js.map +1 -1
  106. package/dist/src/types/modelParams.d.ts +8 -0
  107. package/dist/src/utils/bfsFileSearch.js +2 -6
  108. package/dist/src/utils/bfsFileSearch.js.map +1 -1
  109. package/package.json +8 -7
  110. package/dist/src/core/ContentGeneratorAdapter.d.ts +0 -37
  111. package/dist/src/core/ContentGeneratorAdapter.js +0 -58
  112. package/dist/src/core/ContentGeneratorAdapter.js.map +0 -1
  113. package/dist/src/providers/IMessage.d.ts +0 -38
  114. package/dist/src/providers/IMessage.js +0 -17
  115. package/dist/src/providers/IMessage.js.map +0 -1
  116. package/dist/src/providers/adapters/GeminiCompatibleWrapper.d.ts +0 -69
  117. package/dist/src/providers/adapters/GeminiCompatibleWrapper.js +0 -577
  118. package/dist/src/providers/adapters/GeminiCompatibleWrapper.js.map +0 -1
@@ -17,6 +17,9 @@ import { ApiErrorEvent, ApiRequestEvent, ApiResponseEvent, } from '../telemetry/
17
17
  import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
18
18
  import { hasCycleInSchema } from '../tools/tools.js';
19
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';
20
23
  /**
21
24
  * Custom createUserContent function that properly handles function response arrays.
22
25
  * This fixes the issue where multiple function responses are incorrectly nested.
@@ -56,9 +59,6 @@ function createUserContentWithFunctionResponseFix(message) {
56
59
  }
57
60
  else if (Array.isArray(item)) {
58
61
  // Nested array case - flatten it
59
- if (process.env.DEBUG) {
60
- console.log('[DEBUG] createUserContentWithFunctionResponseFix - flattening nested array:', JSON.stringify(item, null, 2));
61
- }
62
62
  for (const subItem of item) {
63
63
  parts.push(subItem);
64
64
  }
@@ -184,27 +184,59 @@ export class EmptyStreamError extends Error {
184
184
  */
185
185
  export class GeminiChat {
186
186
  config;
187
- contentGenerator;
188
187
  generationConfig;
189
188
  // A promise to represent the current state of the message being sent to the
190
189
  // model.
191
190
  sendPromise = Promise.resolve();
191
+ // A promise to represent any ongoing compression operation
192
+ compressionPromise = null;
192
193
  historyService;
194
+ logger = new DebugLogger('llxprt:gemini:chat');
195
+ // Cache the compression threshold to avoid recalculating
196
+ cachedCompressionThreshold = null;
193
197
  constructor(config, contentGenerator, generationConfig = {}, initialHistory = [], historyService) {
194
198
  this.config = config;
195
- this.contentGenerator = contentGenerator;
196
199
  this.generationConfig = generationConfig;
197
200
  validateHistory(initialHistory);
198
201
  // Use provided HistoryService or create a new one
199
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
+ });
200
208
  // Convert and add initial history if provided
201
209
  if (initialHistory.length > 0) {
202
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();
203
215
  for (const content of initialHistory) {
204
- this.historyService.add(ContentConverters.toIContent(content), currentModel);
216
+ const matcher = this.makePositionMatcher();
217
+ this.historyService.add(ContentConverters.toIContent(content, idGen, matcher), currentModel);
205
218
  }
206
219
  }
207
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
+ };
239
+ }
208
240
  _getRequestTextFromContents(contents) {
209
241
  return JSON.stringify(contents);
210
242
  }
@@ -288,28 +320,79 @@ export class GeminiChat {
288
320
  */
289
321
  async sendMessage(params, prompt_id) {
290
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
+ }
291
335
  const userContent = createUserContentWithFunctionResponseFix(params.message);
292
- // Add user content to history service
293
- this.historyService.add(ContentConverters.toIContent(userContent), this.config.getModel());
294
- // Get curated history and convert to Content[] for the request
295
- const iContents = this.historyService.getCurated();
296
- const requestContents = ContentConverters.toGeminiContents(iContents);
297
- 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);
298
355
  const startTime = Date.now();
299
356
  let response;
300
357
  try {
301
- const apiCall = () => {
358
+ const apiCall = async () => {
302
359
  const modelToUse = this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
303
360
  // Prevent Flash model calls immediately after quota error
304
361
  if (this.config.getQuotaErrorOccurred() &&
305
362
  modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
306
363
  throw new Error('Please submit a new query to continue with the Flash model.');
307
364
  }
308
- return this.contentGenerator.generateContent({
309
- model: modelToUse,
310
- contents: requestContents,
311
- config: { ...this.generationConfig, ...params.config },
312
- }, 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);
313
396
  };
314
397
  response = await retryWithBackoff(apiCall, {
315
398
  shouldRetry: (error) => {
@@ -331,51 +414,46 @@ export class GeminiChat {
331
414
  await this._logApiResponse(durationMs, prompt_id, response.usageMetadata, JSON.stringify(response));
332
415
  this.sendPromise = (async () => {
333
416
  const outputContent = response.candidates?.[0]?.content;
334
- // Because the AFC input contains the entire curated chat history in
335
- // addition to the new user input, we need to truncate the AFC history
336
- // 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
337
420
  const fullAutomaticFunctionCallingHistory = response.automaticFunctionCallingHistory;
338
- const curatedHistory = this.historyService.getCurated();
339
- const index = ContentConverters.toGeminiContents(curatedHistory).length;
340
- let automaticFunctionCallingHistory = [];
341
- if (fullAutomaticFunctionCallingHistory != null) {
342
- automaticFunctionCallingHistory =
343
- fullAutomaticFunctionCallingHistory.slice(index) ?? [];
344
- }
345
- // Note: modelOutput variable no longer used directly since we handle
346
- // responses inline below
347
- // Remove the user content we added and handle AFC history if present
348
- // Only do this if AFC history actually has content
349
- if (automaticFunctionCallingHistory &&
350
- automaticFunctionCallingHistory.length > 0) {
351
- // Pop the user content and replace with AFC history
352
- const allHistory = this.historyService.getAll();
353
- const trimmedHistory = allHistory.slice(0, -1);
354
- this.historyService.clear();
355
- const currentModel = this.config.getModel();
356
- for (const content of trimmedHistory) {
357
- this.historyService.add(content, currentModel);
358
- }
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) ?? [];
359
427
  for (const content of automaticFunctionCallingHistory) {
360
- this.historyService.add(ContentConverters.toIContent(content), currentModel);
428
+ const idGen = this.historyService.getIdGeneratorCallback();
429
+ const matcher = this.makePositionMatcher();
430
+ this.historyService.add(ContentConverters.toIContent(content, idGen, matcher), currentModel);
361
431
  }
362
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
+ }
363
439
  // Add model response if we have one (but filter out pure thinking responses)
364
440
  if (outputContent) {
365
441
  // Check if this is pure thinking content that should be filtered
366
442
  if (!this.isThoughtContent(outputContent)) {
367
443
  // Not pure thinking, add it
368
- this.historyService.add(ContentConverters.toIContent(outputContent), this.config.getModel());
444
+ const idGen = this.historyService.getIdGeneratorCallback();
445
+ this.historyService.add(ContentConverters.toIContent(outputContent, idGen), currentModel);
369
446
  }
370
447
  // If it's pure thinking content, don't add it to history
371
448
  }
372
449
  else if (response.candidates && response.candidates.length > 0) {
373
450
  // We have candidates but no content - add empty model response
374
451
  // This handles the case where the model returns empty content
375
- if (!automaticFunctionCallingHistory ||
376
- automaticFunctionCallingHistory.length === 0) {
452
+ if (!fullAutomaticFunctionCallingHistory ||
453
+ fullAutomaticFunctionCallingHistory.length === 0) {
377
454
  const emptyModelContent = { role: 'model', parts: [] };
378
- this.historyService.add(ContentConverters.toIContent(emptyModelContent), this.config.getModel());
455
+ const idGen = this.historyService.getIdGeneratorCallback();
456
+ this.historyService.add(ContentConverters.toIContent(emptyModelContent, idGen), currentModel);
379
457
  }
380
458
  }
381
459
  // If no candidates at all, don't add anything (error case)
@@ -417,37 +495,72 @@ export class GeminiChat {
417
495
  * ```
418
496
  */
419
497
  async sendMessageStream(params, prompt_id) {
420
- if (process.env.DEBUG) {
421
- console.log('DEBUG [geminiChat]: ===== SEND MESSAGE STREAM START =====');
422
- console.log('DEBUG [geminiChat]: Model from config:', this.config.getModel());
423
- console.log('DEBUG [geminiChat]: Params:', JSON.stringify(params, null, 2));
424
- console.log('DEBUG [geminiChat]: Message type:', typeof params.message);
425
- console.log('DEBUG [geminiChat]: Message content:', JSON.stringify(params.message, null, 2));
426
- }
427
- if (process.env.DEBUG) {
428
- console.log('DEBUG: GeminiChat.sendMessageStream called');
429
- console.log('DEBUG: GeminiChat.sendMessageStream params:', JSON.stringify(params, null, 2));
430
- console.log('DEBUG: GeminiChat.sendMessageStream params.message type:', typeof params.message);
431
- console.log('DEBUG: GeminiChat.sendMessageStream params.message:', JSON.stringify(params.message, null, 2));
432
- }
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)}`);
433
507
  await this.sendPromise;
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;
519
+ }
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
+ {
541
+ role: 'user',
542
+ parts: [messageArray[1]],
543
+ },
544
+ ];
545
+ }
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
434
551
  let streamDoneResolver;
435
552
  const streamDonePromise = new Promise((resolve) => {
436
553
  streamDoneResolver = resolve;
437
554
  });
438
555
  this.sendPromise = streamDonePromise;
439
- const userContent = createUserContentWithFunctionResponseFix(params.message);
440
- // Add user content to history ONCE before any attempts.
441
- this.historyService.add(ContentConverters.toIContent(userContent), this.config.getModel());
442
- // Note: requestContents is no longer needed as adapter gets history from HistoryService
443
- // eslint-disable-next-line @typescript-eslint/no-this-alias
444
- const self = this;
445
- return (async function* () {
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) {
446
559
  try {
447
560
  let lastError = new Error('Request failed after all retries.');
448
561
  for (let attempt = 0; attempt <= INVALID_CONTENT_RETRY_OPTIONS.maxAttempts; attempt++) {
449
562
  try {
450
- const stream = await self.makeApiCallAndProcessStream(params, prompt_id, userContent);
563
+ const stream = await instance.makeApiCallAndProcessStream(params, prompt_id, userContent);
451
564
  for await (const chunk of stream) {
452
565
  yield chunk;
453
566
  }
@@ -469,31 +582,27 @@ export class GeminiChat {
469
582
  }
470
583
  }
471
584
  if (lastError) {
472
- // If the stream fails, remove the user message that was added.
473
- const allHistory = self.historyService.getAll();
474
- const lastIContent = allHistory[allHistory.length - 1];
475
- const userIContent = ContentConverters.toIContent(userContent);
476
- // Check if the last content is the user content we just added
477
- if (lastIContent?.speaker === userIContent.speaker &&
478
- JSON.stringify(lastIContent?.blocks) ===
479
- JSON.stringify(userIContent.blocks)) {
480
- // Remove the last item from history
481
- const trimmedHistory = allHistory.slice(0, -1);
482
- self.historyService.clear();
483
- for (const content of trimmedHistory) {
484
- self.historyService.add(content, self.config.getModel());
485
- }
486
- }
585
+ // With send-then-commit pattern, we don't add to history until success,
586
+ // so there's nothing to remove on failure
487
587
  throw lastError;
488
588
  }
489
589
  }
490
590
  finally {
491
591
  streamDoneResolver();
492
592
  }
493
- })();
593
+ })(this);
494
594
  }
495
- async makeApiCallAndProcessStream(params, prompt_id, userContent) {
496
- const apiCall = () => {
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');
600
+ }
601
+ // Check if provider supports IContent interface
602
+ if (!this.providerSupportsIContent(provider)) {
603
+ throw new Error(`Provider ${provider.name} does not support IContent interface`);
604
+ }
605
+ const apiCall = async () => {
497
606
  const modelToUse = this.config.getModel();
498
607
  const authType = this.config.getContentGeneratorConfig()?.authType;
499
608
  // Prevent Flash model calls immediately after quota error (only for Gemini providers)
@@ -502,14 +611,37 @@ export class GeminiChat {
502
611
  modelToUse === DEFAULT_GEMINI_FLASH_MODEL) {
503
612
  throw new Error('Please submit a new query to continue with the Flash model.');
504
613
  }
505
- // Get curated history for the request
506
- const iContents = this.historyService.getCurated();
507
- const requestContents = ContentConverters.toGeminiContents(iContents);
508
- return this.contentGenerator.generateContentStream({
509
- model: modelToUse,
510
- contents: requestContents,
511
- config: { ...this.generationConfig, ...params.config },
512
- }, prompt_id);
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);
513
645
  };
514
646
  const streamResponse = await retryWithBackoff(apiCall, {
515
647
  shouldRetry: (error) => {
@@ -584,6 +716,188 @@ export class GeminiChat {
584
716
  setTools(tools) {
585
717
  this.generationConfig.tools = tools;
586
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
+ }
587
901
  getFinalUsageMetadata(chunks) {
588
902
  const lastChunkWithMetadata = chunks
589
903
  .slice()
@@ -655,16 +969,20 @@ export class GeminiChat {
655
969
  }
656
970
  }
657
971
  else {
658
- // Guard for streaming calls where the user input might already be in the history.
659
- const allHistory = this.historyService.getAll();
660
- const lastEntry = allHistory[allHistory.length - 1];
661
- const userIContent = ContentConverters.toIContent(userInput);
662
- // Check if user input is already in history
663
- const isAlreadyInHistory = lastEntry &&
664
- lastEntry.speaker === userIContent.speaker &&
665
- JSON.stringify(lastEntry.blocks) ===
666
- JSON.stringify(userIContent.blocks);
667
- if (!isAlreadyInHistory) {
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);
981
+ }
982
+ }
983
+ else {
984
+ // Normal user message
985
+ const userIContent = ContentConverters.toIContent(userInput, idGen, matcher);
668
986
  newHistoryEntries.push(userIContent);
669
987
  }
670
988
  }
@@ -675,6 +993,7 @@ export class GeminiChat {
675
993
  outputContents = nonThoughtModelOutput;
676
994
  }
677
995
  else if (modelOutput.length === 0 &&
996
+ !Array.isArray(userInput) &&
678
997
  !isFunctionResponse(userInput) &&
679
998
  !automaticFunctionCallingHistory) {
680
999
  // Add an empty model response if the model truly returned nothing.
@@ -702,7 +1021,15 @@ export class GeminiChat {
702
1021
  this.historyService.add(entry, currentModel);
703
1022
  }
704
1023
  for (const content of consolidatedOutputContents) {
705
- this.historyService.add(ContentConverters.toIContent(content), currentModel);
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
706
1033
  }
707
1034
  }
708
1035
  hasTextContent(content) {
@@ -855,7 +1182,7 @@ export class GeminiChat {
855
1182
  // Check for potentially problematic cyclic tools with cyclic schemas
856
1183
  // and include a recommendation to remove potentially problematic tools.
857
1184
  if (isStructuredError(error) && isSchemaDepthError(error.message)) {
858
- const tools = (await this.config.getToolRegistry()).getAllTools();
1185
+ const tools = this.config.getToolRegistry().getAllTools();
859
1186
  const cyclicSchemaTools = [];
860
1187
  for (const tool of tools) {
861
1188
  if ((tool.schema.parametersJsonSchema &&
@@ -872,6 +1199,189 @@ export class GeminiChat {
872
1199
  }
873
1200
  }
874
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
+ }
875
1385
  }
876
1386
  /** Visible for Testing */
877
1387
  export function isSchemaDepthError(errorMessage) {