@vybestack/llxprt-code-core 0.1.23 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/dist/src/adapters/IStreamAdapter.d.ts +3 -3
- package/dist/src/auth/oauth-errors.d.ts +173 -0
- package/dist/src/auth/oauth-errors.js +461 -0
- package/dist/src/auth/oauth-errors.js.map +1 -0
- package/dist/src/auth/precedence.d.ts +1 -5
- package/dist/src/auth/precedence.js +28 -48
- package/dist/src/auth/precedence.js.map +1 -1
- package/dist/src/auth/token-store.js +2 -2
- package/dist/src/auth/token-store.js.map +1 -1
- package/dist/src/auth/types.d.ts +4 -4
- package/dist/src/code_assist/codeAssist.js +19 -6
- package/dist/src/code_assist/codeAssist.js.map +1 -1
- package/dist/src/code_assist/oauth2.d.ts +7 -0
- package/dist/src/code_assist/oauth2.js +82 -32
- package/dist/src/code_assist/oauth2.js.map +1 -1
- package/dist/src/code_assist/server.js +15 -4
- package/dist/src/code_assist/server.js.map +1 -1
- package/dist/src/code_assist/setup.js +9 -0
- package/dist/src/code_assist/setup.js.map +1 -1
- package/dist/src/config/index.d.ts +7 -0
- package/dist/src/config/index.js +8 -0
- package/dist/src/config/index.js.map +1 -0
- package/dist/src/core/client.d.ts +15 -20
- package/dist/src/core/client.js +98 -124
- package/dist/src/core/client.js.map +1 -1
- package/dist/src/core/compression-config.d.ts +10 -0
- package/dist/src/core/compression-config.js +17 -0
- package/dist/src/core/compression-config.js.map +1 -0
- package/dist/src/core/coreToolScheduler.js +50 -15
- package/dist/src/core/coreToolScheduler.js.map +1 -1
- package/dist/src/core/geminiChat.d.ts +68 -9
- package/dist/src/core/geminiChat.js +940 -405
- package/dist/src/core/geminiChat.js.map +1 -1
- package/dist/src/core/nonInteractiveToolExecutor.js +70 -19
- package/dist/src/core/nonInteractiveToolExecutor.js.map +1 -1
- package/dist/src/core/prompts.js +35 -25
- package/dist/src/core/prompts.js.map +1 -1
- package/dist/src/core/turn.d.ts +1 -0
- package/dist/src/core/turn.js +8 -6
- package/dist/src/core/turn.js.map +1 -1
- package/dist/src/ide/ide-client.d.ts +1 -1
- package/dist/src/ide/ide-client.js +12 -6
- package/dist/src/ide/ide-client.js.map +1 -1
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +5 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/prompt-config/TemplateEngine.js +17 -0
- package/dist/src/prompt-config/TemplateEngine.js.map +1 -1
- package/dist/src/prompt-config/defaults/core-defaults.js +39 -32
- package/dist/src/prompt-config/defaults/core-defaults.js.map +1 -1
- package/dist/src/prompt-config/defaults/core.md +2 -0
- package/dist/src/prompt-config/defaults/provider-defaults.js +34 -27
- package/dist/src/prompt-config/defaults/provider-defaults.js.map +1 -1
- package/dist/src/prompt-config/defaults/providers/gemini/core.md +270 -0
- package/dist/src/prompt-config/defaults/providers/gemini/models/gemini-2.5-flash/core.md +12 -0
- package/dist/src/prompt-config/defaults/providers/gemini/models/gemini-2.5-flash/gemini-2-5-flash/core.md +12 -0
- package/dist/src/prompt-config/types.d.ts +2 -0
- package/dist/src/providers/BaseProvider.d.ts +39 -13
- package/dist/src/providers/BaseProvider.js +102 -28
- package/dist/src/providers/BaseProvider.js.map +1 -1
- package/dist/src/providers/IProvider.d.ts +17 -3
- package/dist/src/providers/LoggingProviderWrapper.d.ts +10 -3
- package/dist/src/providers/LoggingProviderWrapper.js +33 -27
- package/dist/src/providers/LoggingProviderWrapper.js.map +1 -1
- package/dist/src/providers/ProviderContentGenerator.d.ts +2 -2
- package/dist/src/providers/ProviderContentGenerator.js +9 -6
- package/dist/src/providers/ProviderContentGenerator.js.map +1 -1
- package/dist/src/providers/ProviderManager.d.ts +4 -0
- package/dist/src/providers/ProviderManager.js +6 -0
- package/dist/src/providers/ProviderManager.js.map +1 -1
- package/dist/src/providers/anthropic/AnthropicProvider.d.ts +34 -21
- package/dist/src/providers/anthropic/AnthropicProvider.js +505 -492
- package/dist/src/providers/anthropic/AnthropicProvider.js.map +1 -1
- package/dist/src/providers/gemini/GeminiProvider.d.ts +23 -9
- package/dist/src/providers/gemini/GeminiProvider.js +344 -515
- package/dist/src/providers/gemini/GeminiProvider.js.map +1 -1
- package/dist/src/providers/openai/ConversationCache.d.ts +3 -3
- package/dist/src/providers/openai/IChatGenerateParams.d.ts +9 -4
- package/dist/src/providers/openai/OpenAIProvider.d.ts +46 -96
- package/dist/src/providers/openai/OpenAIProvider.js +532 -1393
- package/dist/src/providers/openai/OpenAIProvider.js.map +1 -1
- package/dist/src/providers/openai/buildResponsesRequest.d.ts +3 -3
- package/dist/src/providers/openai/buildResponsesRequest.js +67 -37
- package/dist/src/providers/openai/buildResponsesRequest.js.map +1 -1
- package/dist/src/providers/openai/estimateRemoteTokens.d.ts +2 -2
- package/dist/src/providers/openai/estimateRemoteTokens.js +21 -8
- package/dist/src/providers/openai/estimateRemoteTokens.js.map +1 -1
- package/dist/src/providers/openai/parseResponsesStream.d.ts +6 -2
- package/dist/src/providers/openai/parseResponsesStream.js +99 -391
- package/dist/src/providers/openai/parseResponsesStream.js.map +1 -1
- package/dist/src/providers/openai/syntheticToolResponses.d.ts +5 -5
- package/dist/src/providers/openai/syntheticToolResponses.js +102 -91
- package/dist/src/providers/openai/syntheticToolResponses.js.map +1 -1
- package/dist/src/providers/openai-responses/OpenAIResponsesProvider.d.ts +89 -0
- package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js +451 -0
- package/dist/src/providers/openai-responses/OpenAIResponsesProvider.js.map +1 -0
- package/dist/src/providers/openai-responses/index.d.ts +1 -0
- package/dist/src/providers/openai-responses/index.js +2 -0
- package/dist/src/providers/openai-responses/index.js.map +1 -0
- package/dist/src/providers/tokenizers/OpenAITokenizer.js +3 -3
- package/dist/src/providers/tokenizers/OpenAITokenizer.js.map +1 -1
- package/dist/src/providers/types.d.ts +1 -1
- package/dist/src/services/ClipboardService.d.ts +19 -0
- package/dist/src/services/ClipboardService.js +66 -0
- package/dist/src/services/ClipboardService.js.map +1 -0
- package/dist/src/services/history/ContentConverters.d.ts +43 -0
- package/dist/src/services/history/ContentConverters.js +325 -0
- package/dist/src/services/history/ContentConverters.js.map +1 -0
- package/dist/src/{providers/IMessage.d.ts → services/history/HistoryEvents.d.ts} +16 -22
- package/dist/src/{providers/IMessage.js → services/history/HistoryEvents.js} +1 -1
- package/dist/src/services/history/HistoryEvents.js.map +1 -0
- package/dist/src/services/history/HistoryService.d.ts +220 -0
- package/dist/src/services/history/HistoryService.js +673 -0
- package/dist/src/services/history/HistoryService.js.map +1 -0
- package/dist/src/services/history/IContent.d.ts +183 -0
- package/dist/src/services/history/IContent.js +104 -0
- package/dist/src/services/history/IContent.js.map +1 -0
- package/dist/src/services/index.d.ts +1 -0
- package/dist/src/services/index.js +1 -0
- package/dist/src/services/index.js.map +1 -1
- package/dist/src/telemetry/types.d.ts +16 -4
- package/dist/src/telemetry/types.js.map +1 -1
- package/dist/src/tools/IToolFormatter.d.ts +2 -2
- package/dist/src/tools/ToolFormatter.d.ts +42 -4
- package/dist/src/tools/ToolFormatter.js +159 -37
- package/dist/src/tools/ToolFormatter.js.map +1 -1
- package/dist/src/tools/doubleEscapeUtils.d.ts +57 -0
- package/dist/src/tools/doubleEscapeUtils.js +241 -0
- package/dist/src/tools/doubleEscapeUtils.js.map +1 -0
- package/dist/src/tools/read-file.d.ts +6 -1
- package/dist/src/tools/read-file.js +25 -11
- package/dist/src/tools/read-file.js.map +1 -1
- package/dist/src/tools/todo-schemas.d.ts +4 -4
- package/dist/src/tools/tools.js +13 -0
- package/dist/src/tools/tools.js.map +1 -1
- package/dist/src/tools/write-file.d.ts +6 -1
- package/dist/src/tools/write-file.js +48 -26
- package/dist/src/tools/write-file.js.map +1 -1
- package/dist/src/types/modelParams.d.ts +8 -0
- package/dist/src/utils/bfsFileSearch.js +2 -6
- package/dist/src/utils/bfsFileSearch.js.map +1 -1
- package/dist/src/utils/schemaValidator.js +16 -1
- package/dist/src/utils/schemaValidator.js.map +1 -1
- package/package.json +8 -7
- package/dist/src/providers/IMessage.js.map +0 -1
- package/dist/src/providers/adapters/GeminiCompatibleWrapper.d.ts +0 -69
- package/dist/src/providers/adapters/GeminiCompatibleWrapper.js +0 -577
- 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 {
|
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
|
-
|
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
|
-
|
186
|
-
|
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
|
-
|
266
|
-
|
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
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
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
|
-
//
|
304
|
-
|
305
|
-
//
|
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
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
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
|
-
|
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
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
}
|
360
|
-
|
361
|
-
|
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
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
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
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
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
|
-
|
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
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
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
|
-
|
448
|
-
|
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
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
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
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
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
|
-
|
530
|
-
|
531
|
-
|
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
|
-
|
562
|
-
|
563
|
-
|
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(
|
695
|
+
return structuredClone(contents);
|
567
696
|
}
|
568
697
|
/**
|
569
698
|
* Clears the chat history.
|
570
699
|
*/
|
571
700
|
clearHistory() {
|
572
|
-
this.
|
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.
|
707
|
+
this.historyService.add(ContentConverters.toIContent(content), this.config.getModel());
|
581
708
|
}
|
582
709
|
setHistory(history) {
|
583
|
-
this.
|
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,
|
596
|
-
const
|
597
|
-
|
598
|
-
let
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
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
|
-
|
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
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
if (!
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
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
|
-
|
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
|
635
|
-
|
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
|
-
|
966
|
+
const curatedAfc = extractCuratedHistory(automaticFunctionCallingHistory);
|
967
|
+
for (const content of curatedAfc) {
|
968
|
+
newHistoryEntries.push(ContentConverters.toIContent(content));
|
969
|
+
}
|
658
970
|
}
|
659
971
|
else {
|
660
|
-
|
661
|
-
|
662
|
-
|
663
|
-
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
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
|
-
|
984
|
+
// Normal user message
|
985
|
+
const userIContent = ContentConverters.toIContent(userInput, idGen, matcher);
|
986
|
+
newHistoryEntries.push(userIContent);
|
679
987
|
}
|
680
988
|
}
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
721
|
-
|
722
|
-
|
723
|
-
|
724
|
-
|
725
|
-
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
|
745
|
-
|
746
|
-
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
|
751
|
-
|
752
|
-
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
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
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
767
|
-
|
768
|
-
|
769
|
-
|
770
|
-
|
771
|
-
|
772
|
-
|
773
|
-
|
774
|
-
|
775
|
-
|
776
|
-
|
777
|
-
|
778
|
-
|
779
|
-
|
780
|
-
|
781
|
-
|
782
|
-
|
783
|
-
|
784
|
-
|
785
|
-
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
790
|
-
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
801
|
-
|
802
|
-
|
803
|
-
|
804
|
-
|
805
|
-
|
806
|
-
|
807
|
-
|
808
|
-
|
809
|
-
|
810
|
-
|
811
|
-
|
812
|
-
|
813
|
-
|
814
|
-
|
815
|
-
|
816
|
-
|
817
|
-
|
818
|
-
|
819
|
-
|
820
|
-
|
821
|
-
|
822
|
-
|
823
|
-
|
824
|
-
|
825
|
-
|
826
|
-
|
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 =
|
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) {
|