diff --git a/runner/codegen/base-cli-agent-runner.ts b/runner/codegen/base-cli-agent-runner.ts new file mode 100644 index 0000000..83dd84a --- /dev/null +++ b/runner/codegen/base-cli-agent-runner.ts @@ -0,0 +1,207 @@ +import {ChildProcess, spawn} from 'child_process'; +import {join, relative} from 'path'; +import {existsSync} from 'fs'; +import assert from 'assert'; +import { + LlmConstrainedOutputGenerateResponse, + LlmGenerateFilesRequestOptions, + LlmGenerateFilesResponse, + LlmGenerateTextResponse, +} from './llm-runner.js'; +import {DirectorySnapshot} from './directory-snapshot.js'; +import {LlmResponseFile} from '../shared-interfaces.js'; +import {UserFacingError} from '../utils/errors.js'; + +/** Base class for a command-line-based runner. */ +export abstract class BaseCliAgentRunner { + abstract readonly displayName: string; + protected abstract readonly binaryName: string; + protected abstract readonly ignoredFilePatterns: string[]; + protected abstract getCommandLineFlags(options: LlmGenerateFilesRequestOptions): string[]; + protected abstract writeAgentFiles(options: LlmGenerateFilesRequestOptions): Promise; + + private pendingTimeouts = new Set>(); + private pendingProcesses = new Set(); + private binaryPath: string | null = null; + private commonIgnoredPatterns = ['**/node_modules/**', '**/dist/**', '**/.angular/**']; + + async generateFiles(options: LlmGenerateFilesRequestOptions): Promise { + const {context} = options; + + // TODO: Consider removing these assertions when we have better types. + assert( + context.buildCommand, + 'Expected a `buildCommand` to be set in the LLM generate request context', + ); + assert( + context.packageManager, + 'Expected a `packageManager` to be set in the LLM generate request context', + ); + + const ignoredPatterns = [...this.commonIgnoredPatterns, ...this.ignoredFilePatterns]; + const initialSnapshot = await DirectorySnapshot.forDirectory( + context.directory, + ignoredPatterns, + ); + + await this.writeAgentFiles(options); + + const reasoning = await this.runAgentProcess(options, 2, 10); + const finalSnapshot = await DirectorySnapshot.forDirectory(context.directory, ignoredPatterns); + + const diff = finalSnapshot.getChangedOrAddedFiles(initialSnapshot); + const files: LlmResponseFile[] = []; + + for (const [absolutePath, code] of diff) { + files.push({ + filePath: relative(context.directory, absolutePath), + code, + }); + } + + return {files, reasoning, toolLogs: []}; + } + + generateText(): Promise { + // Technically we can make this work, but we don't need it at the time of writing. + throw new UserFacingError(`Generating text with ${this.displayName} is not supported.`); + } + + generateConstrained(): Promise> { + // We can't support this, because there's no straightforward + // way to tell the agent to follow a schema. + throw new UserFacingError(`Constrained output with ${this.displayName} is not supported.`); + } + + async dispose(): Promise { + for (const timeout of this.pendingTimeouts) { + clearTimeout(timeout); + } + + for (const childProcess of this.pendingProcesses) { + childProcess.kill('SIGKILL'); + } + + this.pendingTimeouts.clear(); + this.pendingProcesses.clear(); + } + + private resolveBinaryPath(binaryName: string): string { + let dir = import.meta.dirname; + let closestRoot: string | null = null; + + // Attempt to resolve the agent CLI binary by starting at the current file and going up until + // we find the closest `node_modules`. Note that we can't rely on `import.meta.resolve` here, + // because that'll point us to the agent bundle, but not its binary. In some package + // managers (pnpm specifically) the `node_modules` in which the file is installed is different + // from the one in which the binary is placed. + while (dir.length > 1) { + if (existsSync(join(dir, 'node_modules'))) { + closestRoot = dir; + break; + } + + const parent = join(dir, '..'); + + if (parent === dir) { + // We've reached the root, stop traversing. + break; + } else { + dir = parent; + } + } + + const binaryPath = closestRoot ? join(closestRoot, `node_modules/.bin/${binaryName}`) : null; + + if (!binaryPath || !existsSync(binaryPath)) { + throw new UserFacingError(`${this.displayName} is not installed inside the current project`); + } + + return binaryPath; + } + + private runAgentProcess( + options: LlmGenerateFilesRequestOptions, + inactivityTimeoutMins: number, + totalRequestTimeoutMins: number, + ): Promise { + return new Promise(resolve => { + let stdoutBuffer = ''; + let stdErrBuffer = ''; + let isDone = false; + const msPerMin = 1000 * 60; + const finalize = (finalMessage: string) => { + if (isDone) { + return; + } + + isDone = true; + + if (inactivityTimeout) { + clearTimeout(inactivityTimeout); + this.pendingTimeouts.delete(inactivityTimeout); + } + + clearTimeout(globalTimeout); + childProcess.kill('SIGKILL'); + this.pendingTimeouts.delete(globalTimeout); + this.pendingProcesses.delete(childProcess); + + const separator = '\n--------------------------------------------------\n'; + + if (stdErrBuffer.length > 0) { + stdoutBuffer += separator + 'Stderr output:\n' + stdErrBuffer; + } + + stdoutBuffer += separator + finalMessage; + resolve(stdoutBuffer); + }; + + const noOutputCallback = () => { + finalize( + `There was no output from ${this.displayName} for ${inactivityTimeoutMins} minute(s). ` + + `Stopping the process...`, + ); + }; + + // The agent can get into a state where it stops outputting code, but it also doesn't exit + // the process. Stop if there hasn't been any output for a certain amount of time. + let inactivityTimeout = setTimeout(noOutputCallback, inactivityTimeoutMins * msPerMin); + this.pendingTimeouts.add(inactivityTimeout); + + // Also add a timeout for the entire codegen process. + const globalTimeout = setTimeout(() => { + finalize( + `${this.displayName} didn't finish within ${totalRequestTimeoutMins} minute(s). ` + + `Stopping the process...`, + ); + }, totalRequestTimeoutMins * msPerMin); + + this.binaryPath ??= this.resolveBinaryPath(this.binaryName); + + const childProcess = spawn(this.binaryPath, this.getCommandLineFlags(options), { + cwd: options.context.directory, + env: {...process.env}, + }); + + childProcess.on('close', code => + finalize( + `${this.displayName} process has exited` + (code == null ? '.' : ` with ${code} code.`), + ), + ); + childProcess.stdout.on('data', data => { + if (inactivityTimeout) { + this.pendingTimeouts.delete(inactivityTimeout); + clearTimeout(inactivityTimeout); + } + + stdoutBuffer += data.toString(); + inactivityTimeout = setTimeout(noOutputCallback, inactivityTimeoutMins * msPerMin); + this.pendingTimeouts.add(inactivityTimeout); + }); + childProcess.stderr.on('data', data => { + stdErrBuffer += data.toString(); + }); + }); + } +} diff --git a/runner/codegen/gemini-cli/directory-snapshot.ts b/runner/codegen/directory-snapshot.ts similarity index 100% rename from runner/codegen/gemini-cli/directory-snapshot.ts rename to runner/codegen/directory-snapshot.ts diff --git a/runner/codegen/gemini-cli/gemini-cli-runner.ts b/runner/codegen/gemini-cli/gemini-cli-runner.ts index abe4dbb..7cb0fd6 100644 --- a/runner/codegen/gemini-cli/gemini-cli-runner.ts +++ b/runner/codegen/gemini-cli/gemini-cli-runner.ts @@ -1,98 +1,29 @@ -import {ChildProcess, spawn} from 'child_process'; import { LlmConstrainedOutputGenerateResponse, - LlmGenerateFilesContext, LlmGenerateFilesRequestOptions, - LlmGenerateFilesResponse, LlmGenerateTextResponse, LlmRunner, } from '../llm-runner.js'; -import {join, relative} from 'path'; -import {existsSync, mkdirSync} from 'fs'; +import {join} from 'path'; +import {mkdirSync} from 'fs'; import {writeFile} from 'fs/promises'; import { getGeminiIgnoreFile, getGeminiInstructionsFile, getGeminiSettingsFile, } from './gemini-files.js'; -import {DirectorySnapshot} from './directory-snapshot.js'; -import {LlmResponseFile} from '../../shared-interfaces.js'; import {UserFacingError} from '../../utils/errors.js'; -import assert from 'assert'; +import {BaseCliAgentRunner} from '../base-cli-agent-runner.js'; const SUPPORTED_MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; /** Runner that generates code using the Gemini CLI. */ -export class GeminiCliRunner implements LlmRunner { +export class GeminiCliRunner extends BaseCliAgentRunner implements LlmRunner { readonly id = 'gemini-cli'; readonly displayName = 'Gemini CLI'; readonly hasBuiltInRepairLoop = true; - private pendingTimeouts = new Set>(); - private pendingProcesses = new Set(); - private binaryPath = this.resolveBinaryPath(); - private evalIgnoredPatterns = [ - '**/node_modules/**', - '**/dist/**', - '**/.angular/**', - '**/GEMINI.md', - '**/.geminiignore', - ]; - - async generateFiles(options: LlmGenerateFilesRequestOptions): Promise { - const {context, model} = options; - - // TODO: Consider removing these assertions when we have better types here. - // These fields are always set when running in a local environment, and this - // is a requirement for selecting the `gemini-cli` runner. - assert( - context.buildCommand, - 'Expected a `buildCommand` to be set in the LLM generate request context', - ); - assert( - context.packageManager, - 'Expected a `packageManager` to be set in the LLM generate request context', - ); - - const ignoreFilePath = join(context.directory, '.geminiignore'); - const instructionFilePath = join(context.directory, 'GEMINI.md'); - const settingsDir = join(context.directory, '.gemini'); - const initialSnapshot = await DirectorySnapshot.forDirectory( - context.directory, - this.evalIgnoredPatterns, - ); - - mkdirSync(settingsDir); - - await Promise.all([ - writeFile(ignoreFilePath, getGeminiIgnoreFile()), - writeFile( - instructionFilePath, - getGeminiInstructionsFile(context.systemInstructions, context.buildCommand), - ), - writeFile( - join(settingsDir, 'settings.json'), - getGeminiSettingsFile(context.packageManager, context.possiblePackageManagers), - ), - ]); - - const reasoning = await this.runGeminiProcess(model, context, 2, 10); - const finalSnapshot = await DirectorySnapshot.forDirectory( - context.directory, - this.evalIgnoredPatterns, - ); - - const diff = finalSnapshot.getChangedOrAddedFiles(initialSnapshot); - const files: LlmResponseFile[] = []; - - for (const [absolutePath, code] of diff) { - files.push({ - filePath: relative(context.directory, absolutePath), - code, - }); - } - - return {files, reasoning, toolLogs: []}; - } + protected ignoredFilePatterns = ['**/GEMINI.md', '**/.geminiignore']; + protected binaryName = 'gemini'; generateText(): Promise { // Technically we can make this work, but we don't need it at the time of writing. @@ -109,144 +40,42 @@ export class GeminiCliRunner implements LlmRunner { return SUPPORTED_MODELS; } - async dispose(): Promise { - for (const timeout of this.pendingTimeouts) { - clearTimeout(timeout); - } - - for (const childProcess of this.pendingProcesses) { - childProcess.kill('SIGKILL'); - } - - this.pendingTimeouts.clear(); - this.pendingProcesses.clear(); - } - - private resolveBinaryPath(): string { - let dir = import.meta.dirname; - let closestRoot: string | null = null; - - // Attempt to resolve the Gemini CLI binary by starting at the current file and going up until - // we find the closest `node_modules`. Note that we can't rely on `import.meta.resolve` here, - // because that'll point us to the Gemini CLI bundle, but not its binary. In some package - // managers (pnpm specifically) the `node_modules` in which the file is installed is different - // from the one in which the binary is placed. - while (dir.length > 1) { - if (existsSync(join(dir, 'node_modules'))) { - closestRoot = dir; - break; - } - - const parent = join(dir, '..'); - - if (parent === dir) { - // We've reached the root, stop traversing. - break; - } else { - dir = parent; - } - } - - const binaryPath = closestRoot ? join(closestRoot, 'node_modules/.bin/gemini') : null; - - if (!binaryPath || !existsSync(binaryPath)) { - throw new UserFacingError('Gemini CLI is not installed inside the current project'); - } - - return binaryPath; + protected getCommandLineFlags(options: LlmGenerateFilesRequestOptions): string[] { + return [ + '--prompt', + options.context.executablePrompt, + '--model', + options.model, + // Skip all confirmations. + '--approval-mode', + 'yolo', + ]; } - private runGeminiProcess( - model: string, - context: LlmGenerateFilesContext, - inactivityTimeoutMins: number, - totalRequestTimeoutMins: number, - ): Promise { - return new Promise(resolve => { - let stdoutBuffer = ''; - let stdErrBuffer = ''; - let isDone = false; - const msPerMin = 1000 * 60; - const finalize = (finalMessage: string) => { - if (isDone) { - return; - } - - isDone = true; - - if (inactivityTimeout) { - clearTimeout(inactivityTimeout); - this.pendingTimeouts.delete(inactivityTimeout); - } - - clearTimeout(globalTimeout); - childProcess.kill('SIGKILL'); - this.pendingTimeouts.delete(globalTimeout); - this.pendingProcesses.delete(childProcess); - - const separator = '\n--------------------------------------------------\n'; - - if (stdErrBuffer.length > 0) { - stdoutBuffer += separator + 'Stderr output:\n' + stdErrBuffer; - } - - stdoutBuffer += separator + finalMessage; - resolve(stdoutBuffer); - }; - - const noOutputCallback = () => { - finalize( - `There was no output from Gemini CLI for ${inactivityTimeoutMins} minute(s). ` + - `Stopping the process...`, - ); - }; + protected async writeAgentFiles(options: LlmGenerateFilesRequestOptions): Promise { + const {context} = options; + const ignoreFilePath = join(context.directory, '.geminiignore'); + const instructionFilePath = join(context.directory, 'GEMINI.md'); + const settingsDir = join(context.directory, '.gemini'); - // Gemini can get into a state where it stops outputting code, but it also doesn't exit - // the process. Stop if there hasn't been any output for a certain amount of time. - let inactivityTimeout = setTimeout(noOutputCallback, inactivityTimeoutMins * msPerMin); - this.pendingTimeouts.add(inactivityTimeout); + mkdirSync(settingsDir); - // Also add a timeout for the entire codegen process. - const globalTimeout = setTimeout(() => { - finalize( - `Gemini CLI didn't finish within ${totalRequestTimeoutMins} minute(s). ` + - `Stopping the process...`, - ); - }, totalRequestTimeoutMins * msPerMin); + const promises: Promise[] = [writeFile(ignoreFilePath, getGeminiIgnoreFile())]; - const childProcess = spawn( - this.binaryPath, - [ - '--prompt', - context.executablePrompt, - '--model', - model, - // Skip all confirmations. - '--approval-mode', - 'yolo', - ], - { - cwd: context.directory, - env: {...process.env}, - }, + if (context.buildCommand) { + promises.push( + writeFile( + instructionFilePath, + getGeminiInstructionsFile(context.systemInstructions, context.buildCommand), + ), ); + } - childProcess.on('close', code => - finalize('Gemini CLI process has exited' + (code == null ? '.' : ` with ${code} code.`)), + if (context.packageManager) { + writeFile( + join(settingsDir, 'settings.json'), + getGeminiSettingsFile(context.packageManager, context.possiblePackageManagers), ); - childProcess.stdout.on('data', data => { - if (inactivityTimeout) { - this.pendingTimeouts.delete(inactivityTimeout); - clearTimeout(inactivityTimeout); - } - - stdoutBuffer += data.toString(); - inactivityTimeout = setTimeout(noOutputCallback, inactivityTimeoutMins * msPerMin); - this.pendingTimeouts.add(inactivityTimeout); - }); - childProcess.stderr.on('data', data => { - stdErrBuffer += data.toString(); - }); - }); + } } }