UNPKG

@continue-reasoning/agent

Version:

A platform-agnostic AI agent framework for building autonomous AI agents with tool execution capabilities

640 lines 24.9 kB
/** * @fileoverview Platform-agnostic GeminiChat implementation * * This module provides a GeminiChat implementation that follows the IChat interface * and integrates with our Agent framework. It references the core package patterns * but uses our own type system and token tracking. * * Key features: * - Streaming-first approach for real-time responses * - Integrated token tracking with ITokenTracker * - Dual history system (comprehensive vs curated) * - Platform-agnostic content types * - Robust error handling and validation */ import { GoogleGenAI, FunctionCallingConfigMode, } from '@google/genai'; import { TokenTracker } from './tokenTracker.js'; import { LogLevel, createLogger } from './logger.js'; function isThinkingSupported(model) { if (model.startsWith('gemini-2.5')) return true; return false; } /** * GeminiChat implementation using our platform-agnostic interfaces * * This class provides streaming chat functionality with integrated token tracking * and conversation history management. It implements the IChat interface and works * with our ConversationContent type system while interfacing with Google's Gemini API. * * Key implementation details: * - Uses Google's GenerativeAI SDK directly * - Converts between our types and Gemini's types * - Maintains conversation history in our format * - Provides real-time token tracking * - Supports streaming and non-streaming responses */ export class GeminiChat { chatConfig; history = []; tokenTracker; sendPromise = Promise.resolve(); isCurrentlyProcessing = false; aiClient; contentGenerator; generateContentConfig; logger; constructor(chatConfig) { this.chatConfig = chatConfig; this.chatConfig = chatConfig; this.logger = createLogger('GeminiChat', { level: LogLevel.INFO }); this.logger.debug(`Initializing GeminiChat with model: ${chatConfig.modelName}`, 'GeminiChat.constructor()'); this.aiClient = new GoogleGenAI({ apiKey: chatConfig.apiKey }); this.contentGenerator = this.aiClient.models; this.history = [...chatConfig.initialHistory || []]; this.tokenTracker = new TokenTracker(chatConfig.modelName, chatConfig.tokenLimit); let config = { systemInstruction: chatConfig.systemPrompt || '', }; // Only add tools if we have tool declarations if (chatConfig.toolDeclarations && chatConfig.toolDeclarations.length > 0) { config.tools = [{ functionDeclarations: chatConfig.toolDeclarations.map(tool => { const declaration = { name: tool.name, description: tool.description, }; if (tool.parameters) { // Convert Type enum values (uppercase) to lowercase for Gemini API declaration.parameters = this.convertTypesToLowercase(tool.parameters); } return declaration; }) }]; // Force the model to call 'any' function, instead of chatting. config.toolConfig = { functionCallingConfig: { // see https://ai.google.dev/gemini-api/docs/function-calling?example=meeting#function_calling_modes mode: chatConfig.parallelToolCalls ? FunctionCallingConfigMode.ANY : FunctionCallingConfigMode.AUTO } }; } this.generateContentConfig = isThinkingSupported(chatConfig.modelName) ? { ...config, thinkingConfig: { includeThoughts: true, }, } : config; } /** * Send a message and get streaming response * * Implements the IChat interface for streaming message sending. * Converts our ChatMessage format to Gemini's format and processes * the streaming response. * * @param message - Message in our ChatMessage format * @param promptId - Unique identifier for this prompt * @returns AsyncGenerator yielding LLMResponse objects */ async sendMessageStream(message, promptId) { // Return immediately with an AsyncGenerator that handles initialization internally return this.createStreamingResponse(message, promptId); } /** * Create streaming response with internal initialization * * This method immediately returns an AsyncGenerator and handles all initialization * (connection, auth, retries) internally within the generator. This eliminates * the initial await delay and provides true streaming from the first moment. */ async *createStreamingResponse(message, promptId) { await this.sendPromise; this.isCurrentlyProcessing = true; // Create a promise to track completion and set it immediately let completionResolve; let completionReject; this.sendPromise = new Promise((resolve, reject) => { completionResolve = resolve; completionReject = reject; }); const messagePreview = typeof message.content === 'string' ? message.content.slice(0, 100) + (message.content.length > 100 ? '...' : '') : `${message.content.length} content parts`; this.logger.info(`Sending message stream (${promptId}): ${messagePreview}`, 'GeminiChat.createStreamingResponse()'); try { // Convert our message format to Gemini format const userContent = this.convertToGeminiContent(message.content, 'user'); let requestContents = this.getGeminiHistory(true); requestContents = requestContents.concat([userContent]); this.logger.debug(`Request contains ${requestContents.length} content items`, 'GeminiChat.createStreamingResponse()'); // Create request (system prompt will be handled via conversation history) // Deep clone the config to avoid mutations const requestConfig = JSON.parse(JSON.stringify(this.generateContentConfig)); const request = { model: this.chatConfig.modelName, contents: requestContents, config: requestConfig, }; this.logger.debug(`Calling Gemini API with model: ${this.chatConfig.modelName}`, 'GeminiChat.createStreamingResponse()'); // Initialize the stream inside the generator - this is where the await happens // But from the caller's perspective, streaming has already begun const streamResponse = await this.contentGenerator.generateContentStream(request); // Now stream the actual responses yield* this.processStreamResponseInternal(streamResponse, this.convertFromGeminiContent(userContent), promptId); // Stream completed successfully completionResolve(); } catch (error) { this.isCurrentlyProcessing = false; this.logger.error(`Error in createStreamingResponse: ${error instanceof Error ? error.message : String(error)}`, 'GeminiChat.createStreamingResponse()'); completionReject(error); throw error; } } /** * Internal stream processing without separate async generator return * * This is the core streaming logic extracted to work directly within * the main streaming generator. */ async *processStreamResponseInternal(streamResponse, inputContent, promptId) { const outputContent = []; let errorOccurred = false; let responseId = 0; let chunkCount = 0; this.logger.debug(`Processing stream response for prompt: ${promptId}`, 'GeminiChat.processStreamResponseInternal()'); try { // Handle streaming response from the API for await (const chunk of streamResponse) { chunkCount++; // Update token tracking with each chunk if (chunk.usageMetadata) { this.tokenTracker.updateUsage({ inputTokens: chunk.usageMetadata.promptTokenCount || 0, outputTokens: chunk.usageMetadata.candidatesTokenCount || 0, }); this.logger.debug(`Token usage updated - Input: ${chunk.usageMetadata.promptTokenCount}, Output: ${chunk.usageMetadata.candidatesTokenCount}`, 'GeminiChat.processStreamResponseInternal()'); } const parts = chunk.candidates?.[0]?.content?.parts; // Skip chunks without parts (can happen during streaming) if (!parts || parts.length === 0) { this.logger.debug(`Skipping chunk ${chunkCount} - no parts`, 'GeminiChat.processStreamResponseInternal()'); continue; } // Convert chunk to our response format const llmResponse = this.convertToLLMResponse(chunk, `${promptId}_${responseId++}`); // Collect valid content for history if (this.isValidLLMResponse(llmResponse)) { outputContent.push(llmResponse.content); } yield llmResponse; } this.logger.debug(`Stream processing completed - ${chunkCount} chunks processed, ${outputContent.length} valid responses`, 'GeminiChat.processStreamResponseInternal()'); } catch (error) { errorOccurred = true; this.logger.error(`Error processing stream response: ${error instanceof Error ? error.message : String(error)}`, 'GeminiChat.processStreamResponseInternal()'); throw error; } finally { if (!errorOccurred) { // Update history after successful streaming this.logger.debug(`Recording history - input + ${outputContent.length} responses`, 'GeminiChat.processStreamResponseInternal()'); this.recordHistory(inputContent, outputContent); } this.isCurrentlyProcessing = false; } } /** * Validate LLM response in our format * * Checks if the response contains valid content that should be * included in conversation history. * * @param response - LLM response to validate * @returns True if response is valid */ isValidLLMResponse(response) { return this.isValidConversationContent(response.content); } /** * Validate conversation content * * Validates content structure and ensures it contains meaningful data. * Filters out empty text and invalid parts. * * @param content - ConversationContent to validate * @returns True if content is valid */ isValidConversationContent(content) { if (!content.parts || content.parts.length === 0) { return false; } for (const part of content.parts) { if (!part || Object.keys(part).length === 0) { return false; } // Check for empty text (but allow other content types) if (part.type === 'text' && part.text !== undefined && part.text.trim() === '') { return false; } } return true; } /** * Record history after successful interaction * * Adds user input and assistant response to conversation history, * maintaining proper role alternation and filtering invalid content. * * @param userInput - User input in our format * @param modelOutput - Assistant output in our format */ recordHistory(userInput, modelOutput) { // Add user input this.history.push(userInput); // Consolidate and add valid model outputs const validOutputs = modelOutput.filter(content => this.isValidConversationContent(content)); if (validOutputs.length > 0) { // Consolidate multiple assistant responses into one const consolidatedResponse = this.consolidateAssistantResponses(validOutputs); this.history.push(consolidatedResponse); } else { // Add empty assistant response to maintain alternating pattern this.history.push({ role: 'assistant', parts: [{ type: 'text', text: '', }], metadata: { timestamp: Date.now(), empty: true, }, }); } } /** * Extract curated history (valid interactions only) * * Filters conversation history to include only valid user-assistant * interactions, removing any turns with invalid responses. * * @param history - Full conversation history * @returns Curated history with only valid interactions */ extractCuratedHistory(history) { const curatedHistory = []; let i = 0; while (i < history.length) { if (history[i].role === 'user') { const userMessage = history[i]; curatedHistory.push(userMessage); i++; // Look for corresponding assistant response const assistantResponses = []; let isValid = true; while (i < history.length && history[i].role === 'assistant') { assistantResponses.push(history[i]); if (isValid && !this.isValidConversationContent(history[i])) { isValid = false; } i++; } if (isValid && assistantResponses.length > 0) { curatedHistory.push(...assistantResponses); } else { // Remove the corresponding user input if assistant output is invalid curatedHistory.pop(); } } else { // Skip orphaned assistant messages i++; } } return curatedHistory; } /** * Get conversation history * * Returns conversation history in our ConversationContent format. * Can optionally return only curated (valid) history. * * @param curated - Whether to return only valid interactions * @returns Array of conversation content */ getHistory(curated = false) { const history = curated ? this.extractCuratedHistory(this.history) : this.history; return structuredClone(history); // Deep copy to prevent external mutations } /** * Clear conversation history */ clearHistory() { this.history = []; this.tokenTracker.reset(); } /** * Add content to conversation history * * Adds a single conversation content item to the history. * * @param content - Content to add in our format */ addHistory(content) { this.history.push(content); } /** * Set entire conversation history * * Replaces the entire conversation history with new content. * Resets token tracking since usage context changes. * * @param history - New conversation history in our format */ setHistory(history) { this.history = [...history]; this.tokenTracker.reset(); // Reset token tracking when setting new history } /** * Get current token usage tracking */ getTokenUsage() { return this.tokenTracker.getUsage(); } /** * Get token tracker instance */ getTokenTracker() { return this.tokenTracker; } /** * Check if chat is currently processing a message */ isProcessing() { return this.isCurrentlyProcessing; } /** * Get current model information */ getModelInfo() { return { model: this.chatConfig.modelName, tokenLimit: this.chatConfig.tokenLimit, }; } /** * Set system prompt * * Updates the system prompt used for subsequent conversations. * * @param systemPrompt - New system prompt text */ setSystemPrompt(systemPrompt) { this.generateContentConfig.systemInstruction = systemPrompt; } /** * Get current system prompt * * @returns Current system prompt or undefined if not set */ getSystemPrompt() { return this.generateContentConfig.systemInstruction; } /** * Handle model fallback (e.g., pro -> flash) * * Attempts to switch to a fallback model when the current model * encounters issues (quota, rate limits, etc.). * * @param fallbackModel - Model name to fallback to * @returns True if fallback was successful */ handleModelFallback(_fallbackModel) { try { // Check if API key is available const apiKey = process.env.GOOGLE_API_KEY; if (!apiKey || apiKey.trim() === '') { console.warn('No API key available for model fallback'); return false; } // Create new client instance with fallback model this.aiClient = new GoogleGenAI({ apiKey }); this.contentGenerator = this.aiClient.models; return true; } catch (error) { console.warn('Failed to switch to fallback model:', error); return false; } } /** * Get usage summary for debugging * * @returns Formatted usage summary string */ getUsageSummary() { return this.tokenTracker.getUsageSummary(); } // ============================================================================ // TYPE CONVERSION METHODS // ============================================================================ /** * Convert our content format to Gemini's format * * @param content - Content in our format (string or ContentPart[]) * @param role - Content role * @returns Gemini Content object */ convertToGeminiContent(content, role) { if (typeof content === 'string') { return { role: role === 'assistant' ? 'model' : role, parts: [{ text: content }], }; } const geminiParts = content.map(part => { switch (part.type) { case 'text': return { text: part.text || '' }; case 'function_call': return { functionCall: { name: part.functionCall?.name || '', id: part.functionCall?.id || ``, args: part.functionCall?.args || {}, }, }; case 'function_response': return { functionResponse: { name: part.functionResponse?.name || '', id: part.functionResponse?.id || '', response: part.functionResponse?.result, }, }; default: return { text: part.text || JSON.stringify(part) }; } }); return { role: role === 'assistant' ? 'model' : role, parts: geminiParts, }; } /** * Convert Gemini content to our format * * @param geminiContent - Gemini Content object * @returns ConversationContent in our format */ convertFromGeminiContent(geminiContent) { const parts = (geminiContent.parts || []).map(part => { if ('text' in part) { return { type: 'text', text: part.text, }; } if ('functionCall' in part) { return { type: 'function_call', functionCall: { id: part.functionCall?.id || `call_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, name: part.functionCall?.name || '', args: part.functionCall?.args || {}, }, }; } if ('functionResponse' in part) { return { type: 'function_response', functionResponse: { id: `response_${Date.now()}`, name: part.functionResponse?.name || '', result: part.functionResponse?.response, }, }; } // Fallback for unknown part types return { type: 'text', text: JSON.stringify(part), }; }); return { role: geminiContent.role === 'model' ? 'assistant' : geminiContent.role, parts, metadata: { timestamp: Date.now(), }, }; } /** * Convert Gemini response to our LLMResponse format * * @param geminiResponse - Gemini GenerateContentResult * @param responseId - Unique response ID * @returns LLMResponse in our format */ convertToLLMResponse(geminiResponse, responseId) { const candidate = geminiResponse.candidates?.[0]; this.logger.debug(`Gemini response parts: ${JSON.stringify(candidate?.content?.parts, null, 2)}`, 'GeminiChat.convertToLLMResponse()'); let content; if (candidate?.content) { content = this.convertFromGeminiContent(candidate.content); } else { // Empty response content = { role: 'assistant', parts: [], metadata: { timestamp: Date.now(), empty: true, }, }; } const response = { id: responseId, content, model: this.chatConfig.modelName, metadata: { timestamp: Date.now(), promptId: responseId.split('_')[0], }, }; if (geminiResponse.usageMetadata) { response.usage = { inputTokens: geminiResponse.usageMetadata.promptTokenCount || 0, outputTokens: geminiResponse.usageMetadata.candidatesTokenCount || 0, totalTokens: geminiResponse.usageMetadata.totalTokenCount || 0, }; } return response; } /** * Get conversation history in Gemini format * * @param curated - Whether to return curated history * @returns History in Gemini's Content format */ getGeminiHistory(curated = false) { const history = this.getHistory(curated); return history.map(content => this.convertToGeminiContent(content.parts, content.role)); } /** * Convert Type enum values (OBJECT, NUMBER, STRING) to lowercase for Gemini API */ convertTypesToLowercase(obj) { if (!obj || typeof obj !== 'object') return obj; if (Array.isArray(obj)) { return obj.map(item => this.convertTypesToLowercase(item)); } const result = { ...obj }; // Convert type field if it exists if (result.type && typeof result.type === 'string') { result.type = result.type.toLowerCase(); } // Recursively convert nested objects Object.keys(result).forEach(key => { if (typeof result[key] === 'object' && result[key] !== null) { result[key] = this.convertTypesToLowercase(result[key]); } }); return result; } /** * Consolidate multiple assistant responses into one * * @param responses - Array of assistant responses * @returns Single consolidated response */ consolidateAssistantResponses(responses) { const allParts = []; const metadata = { timestamp: Date.now(), consolidated: true, originalCount: responses.length, }; for (const response of responses) { allParts.push(...response.parts); if (response.metadata) { Object.assign(metadata, response.metadata); } } return { role: 'assistant', parts: allParts, metadata, }; } } //# sourceMappingURL=geminiChat.js.map