/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { GenerateContentConfig, GoogleGenAI, Part, Chat, SchemaUnion, PartListUnion, Content, Tool, } from '@google/genai'; import process from 'node:process'; import { getFolderStructure } from '../utils/getFolderStructure.js'; import { Turn, ServerGeminiStreamEvent } from './turn.js'; import { Config } from '../config/config.js'; import { getCoreSystemPrompt } from './prompts.js'; import { ReadManyFilesTool } from '../tools/read-many-files.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { reportError } from '../utils/errorReporting.js'; export class GeminiClient { private client: GoogleGenAI; private model: string; private generateContentConfig: GenerateContentConfig = { temperature: 0, topP: 1, }; private readonly MAX_TURNS = 100; constructor(private config: Config) { const userAgent = config.getUserAgent(); const apiKeyFromConfig = config.getApiKey(); this.client = new GoogleGenAI({ apiKey: apiKeyFromConfig === '' ? undefined : apiKeyFromConfig, httpOptions: { headers: { 'User-Agent': userAgent, }, }, }); this.model = config.getModel(); } private async getEnvironment(): Promise { const cwd = process.cwd(); const today = new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); const platform = process.platform; const folderStructure = await getFolderStructure(cwd); const context = ` Okay, just setting up the context for our chat. Today is ${today}. My operating system is: ${platform} I'm currently working in the directory: ${cwd} ${folderStructure} `.trim(); const initialParts: Part[] = [{ text: context }]; // Add full file context if the flag is set if (this.config.getFullContext()) { try { const readManyFilesTool = this.config .getToolRegistry() .getTool('read_many_files') as ReadManyFilesTool; if (readManyFilesTool) { // Read all files in the target directory const result = await readManyFilesTool.execute( { paths: ['**/*'], // Read everything recursively useDefaultExcludes: true, // Use default excludes }, AbortSignal.timeout(30000), ); if (result.llmContent) { initialParts.push({ text: `\n--- Full File Context ---\n${result.llmContent}`, }); } else { console.warn( 'Full context requested, but read_many_files returned no content.', ); } } else { console.warn( 'Full context requested, but read_many_files tool not found.', ); } } catch (error) { // Not using reportError here as it's a startup/config phase, not a chat/generation phase error. console.error('Error reading full file context:', error); initialParts.push({ text: '\n--- Error reading full file context ---', }); } } return initialParts; } async startChat(): Promise { const envParts = await this.getEnvironment(); const toolDeclarations = this.config .getToolRegistry() .getFunctionDeclarations(); const tools: Tool[] = [{ functionDeclarations: toolDeclarations }]; const history: Content[] = [ { role: 'user', parts: envParts, }, { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }], }, ]; try { const userMemory = this.config.getUserMemory(); const systemInstruction = getCoreSystemPrompt(userMemory); return this.client.chats.create({ model: this.model, config: { systemInstruction, ...this.generateContentConfig, tools, }, history, }); } catch (error) { await reportError( error, 'Error initializing Gemini chat session.', history, 'startChat', ); const message = error instanceof Error ? error.message : 'Unknown error.'; throw new Error(`Failed to initialize chat: ${message}`); } } async *sendMessageStream( chat: Chat, request: PartListUnion, signal?: AbortSignal, turns: number = this.MAX_TURNS, ): AsyncGenerator { if (!turns) { return; } const turn = new Turn(chat); const resultStream = turn.run(request, signal); for await (const event of resultStream) { yield event; } if (!turn.pendingToolCalls.length) { const nextSpeakerCheck = await checkNextSpeaker(chat, this); if (nextSpeakerCheck?.next_speaker === 'model') { const nextRequest = [{ text: 'Please continue.' }]; return this.sendMessageStream(chat, nextRequest, signal, turns - 1); } } } async generateJson( contents: Content[], schema: SchemaUnion, model: string = 'gemini-2.0-flash', config: GenerateContentConfig = {}, ): Promise> { try { const userMemory = this.config.getUserMemory(); const systemInstruction = getCoreSystemPrompt(userMemory); const requestConfig = { ...this.generateContentConfig, ...config, }; const result = await this.client.models.generateContent({ model, config: { ...requestConfig, systemInstruction, responseSchema: schema, responseMimeType: 'application/json', }, contents, }); const text = getResponseText(result); if (!text) { const error = new Error( 'API returned an empty response for generateJson.', ); await reportError( error, 'Error in generateJson: API returned an empty response.', contents, 'generateJson-empty-response', ); throw error; } try { return JSON.parse(text); } catch (parseError) { await reportError( parseError, 'Failed to parse JSON response from generateJson.', { responseTextFailedToParse: text, originalRequestContents: contents, }, 'generateJson-parse', ); throw new Error( `Failed to parse API response as JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, ); } } catch (error) { // Avoid double reporting for the empty response case handled above if ( error instanceof Error && error.message === 'API returned an empty response for generateJson.' ) { throw error; } await reportError( error, 'Error generating JSON content via API.', contents, 'generateJson-api', ); const message = error instanceof Error ? error.message : 'Unknown API error.'; throw new Error(`Failed to generate JSON content: ${message}`); } } }