Skip to content

Commit fa629ce

Browse files
committed
refactor: switch Gemini CLI runner to common base class
Reworks the Gemini CLI runner to use the common logic from the `BaseCliAgentRunner`.
1 parent f128722 commit fa629ce

File tree

1 file changed

+35
-206
lines changed

1 file changed

+35
-206
lines changed
Lines changed: 35 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,29 @@
1-
import {ChildProcess, spawn} from 'child_process';
21
import {
32
LlmConstrainedOutputGenerateResponse,
4-
LlmGenerateFilesContext,
53
LlmGenerateFilesRequestOptions,
6-
LlmGenerateFilesResponse,
74
LlmGenerateTextResponse,
85
LlmRunner,
96
} from '../llm-runner.js';
10-
import {join, relative} from 'path';
11-
import {existsSync, mkdirSync} from 'fs';
7+
import {join} from 'path';
8+
import {mkdirSync} from 'fs';
129
import {writeFile} from 'fs/promises';
1310
import {
1411
getGeminiIgnoreFile,
1512
getGeminiInstructionsFile,
1613
getGeminiSettingsFile,
1714
} from './gemini-files.js';
18-
import {DirectorySnapshot} from '../directory-snapshot.js';
19-
import {LlmResponseFile} from '../../shared-interfaces.js';
2015
import {UserFacingError} from '../../utils/errors.js';
21-
import assert from 'assert';
16+
import {BaseCliAgentRunner} from '../base-cli-agent-runner.js';
2217

2318
const SUPPORTED_MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];
2419

2520
/** Runner that generates code using the Gemini CLI. */
26-
export class GeminiCliRunner implements LlmRunner {
21+
export class GeminiCliRunner extends BaseCliAgentRunner implements LlmRunner {
2722
readonly id = 'gemini-cli';
2823
readonly displayName = 'Gemini CLI';
2924
readonly hasBuiltInRepairLoop = true;
30-
private pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
31-
private pendingProcesses = new Set<ChildProcess>();
32-
private binaryPath = this.resolveBinaryPath();
33-
private evalIgnoredPatterns = [
34-
'**/node_modules/**',
35-
'**/dist/**',
36-
'**/.angular/**',
37-
'**/GEMINI.md',
38-
'**/.geminiignore',
39-
];
40-
41-
async generateFiles(options: LlmGenerateFilesRequestOptions): Promise<LlmGenerateFilesResponse> {
42-
const {context, model} = options;
43-
44-
// TODO: Consider removing these assertions when we have better types here.
45-
// These fields are always set when running in a local environment, and this
46-
// is a requirement for selecting the `gemini-cli` runner.
47-
assert(
48-
context.buildCommand,
49-
'Expected a `buildCommand` to be set in the LLM generate request context',
50-
);
51-
assert(
52-
context.packageManager,
53-
'Expected a `packageManager` to be set in the LLM generate request context',
54-
);
55-
56-
const ignoreFilePath = join(context.directory, '.geminiignore');
57-
const instructionFilePath = join(context.directory, 'GEMINI.md');
58-
const settingsDir = join(context.directory, '.gemini');
59-
const initialSnapshot = await DirectorySnapshot.forDirectory(
60-
context.directory,
61-
this.evalIgnoredPatterns,
62-
);
63-
64-
mkdirSync(settingsDir);
65-
66-
await Promise.all([
67-
writeFile(ignoreFilePath, getGeminiIgnoreFile()),
68-
writeFile(
69-
instructionFilePath,
70-
getGeminiInstructionsFile(context.systemInstructions, context.buildCommand),
71-
),
72-
writeFile(
73-
join(settingsDir, 'settings.json'),
74-
getGeminiSettingsFile(context.packageManager, context.possiblePackageManagers),
75-
),
76-
]);
77-
78-
const reasoning = await this.runGeminiProcess(model, context, 2, 10);
79-
const finalSnapshot = await DirectorySnapshot.forDirectory(
80-
context.directory,
81-
this.evalIgnoredPatterns,
82-
);
83-
84-
const diff = finalSnapshot.getChangedOrAddedFiles(initialSnapshot);
85-
const files: LlmResponseFile[] = [];
86-
87-
for (const [absolutePath, code] of diff) {
88-
files.push({
89-
filePath: relative(context.directory, absolutePath),
90-
code,
91-
});
92-
}
93-
94-
return {files, reasoning, toolLogs: []};
95-
}
25+
protected ignoredFilePatterns = ['**/GEMINI.md', '**/.geminiignore'];
26+
protected binaryName = 'gemini';
9627

9728
generateText(): Promise<LlmGenerateTextResponse> {
9829
// 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 {
10940
return SUPPORTED_MODELS;
11041
}
11142

112-
async dispose(): Promise<void> {
113-
for (const timeout of this.pendingTimeouts) {
114-
clearTimeout(timeout);
115-
}
116-
117-
for (const childProcess of this.pendingProcesses) {
118-
childProcess.kill('SIGKILL');
119-
}
120-
121-
this.pendingTimeouts.clear();
122-
this.pendingProcesses.clear();
123-
}
124-
125-
private resolveBinaryPath(): string {
126-
let dir = import.meta.dirname;
127-
let closestRoot: string | null = null;
128-
129-
// Attempt to resolve the Gemini CLI binary by starting at the current file and going up until
130-
// we find the closest `node_modules`. Note that we can't rely on `import.meta.resolve` here,
131-
// because that'll point us to the Gemini CLI bundle, but not its binary. In some package
132-
// managers (pnpm specifically) the `node_modules` in which the file is installed is different
133-
// from the one in which the binary is placed.
134-
while (dir.length > 1) {
135-
if (existsSync(join(dir, 'node_modules'))) {
136-
closestRoot = dir;
137-
break;
138-
}
139-
140-
const parent = join(dir, '..');
141-
142-
if (parent === dir) {
143-
// We've reached the root, stop traversing.
144-
break;
145-
} else {
146-
dir = parent;
147-
}
148-
}
149-
150-
const binaryPath = closestRoot ? join(closestRoot, 'node_modules/.bin/gemini') : null;
151-
152-
if (!binaryPath || !existsSync(binaryPath)) {
153-
throw new UserFacingError('Gemini CLI is not installed inside the current project');
154-
}
155-
156-
return binaryPath;
43+
protected getCommandLineFlags(options: LlmGenerateFilesRequestOptions): string[] {
44+
return [
45+
'--prompt',
46+
options.context.executablePrompt,
47+
'--model',
48+
options.model,
49+
// Skip all confirmations.
50+
'--approval-mode',
51+
'yolo',
52+
];
15753
}
15854

159-
private runGeminiProcess(
160-
model: string,
161-
context: LlmGenerateFilesContext,
162-
inactivityTimeoutMins: number,
163-
totalRequestTimeoutMins: number,
164-
): Promise<string> {
165-
return new Promise<string>(resolve => {
166-
let stdoutBuffer = '';
167-
let stdErrBuffer = '';
168-
let isDone = false;
169-
const msPerMin = 1000 * 60;
170-
const finalize = (finalMessage: string) => {
171-
if (isDone) {
172-
return;
173-
}
174-
175-
isDone = true;
176-
177-
if (inactivityTimeout) {
178-
clearTimeout(inactivityTimeout);
179-
this.pendingTimeouts.delete(inactivityTimeout);
180-
}
181-
182-
clearTimeout(globalTimeout);
183-
childProcess.kill('SIGKILL');
184-
this.pendingTimeouts.delete(globalTimeout);
185-
this.pendingProcesses.delete(childProcess);
186-
187-
const separator = '\n--------------------------------------------------\n';
188-
189-
if (stdErrBuffer.length > 0) {
190-
stdoutBuffer += separator + 'Stderr output:\n' + stdErrBuffer;
191-
}
192-
193-
stdoutBuffer += separator + finalMessage;
194-
resolve(stdoutBuffer);
195-
};
196-
197-
const noOutputCallback = () => {
198-
finalize(
199-
`There was no output from Gemini CLI for ${inactivityTimeoutMins} minute(s). ` +
200-
`Stopping the process...`,
201-
);
202-
};
55+
protected async writeAgentFiles(options: LlmGenerateFilesRequestOptions): Promise<void> {
56+
const {context} = options;
57+
const ignoreFilePath = join(context.directory, '.geminiignore');
58+
const instructionFilePath = join(context.directory, 'GEMINI.md');
59+
const settingsDir = join(context.directory, '.gemini');
20360

204-
// Gemini can get into a state where it stops outputting code, but it also doesn't exit
205-
// the process. Stop if there hasn't been any output for a certain amount of time.
206-
let inactivityTimeout = setTimeout(noOutputCallback, inactivityTimeoutMins * msPerMin);
207-
this.pendingTimeouts.add(inactivityTimeout);
61+
mkdirSync(settingsDir);
20862

209-
// Also add a timeout for the entire codegen process.
210-
const globalTimeout = setTimeout(() => {
211-
finalize(
212-
`Gemini CLI didn't finish within ${totalRequestTimeoutMins} minute(s). ` +
213-
`Stopping the process...`,
214-
);
215-
}, totalRequestTimeoutMins * msPerMin);
63+
const promises: Promise<unknown>[] = [writeFile(ignoreFilePath, getGeminiIgnoreFile())];
21664

217-
const childProcess = spawn(
218-
this.binaryPath,
219-
[
220-
'--prompt',
221-
context.executablePrompt,
222-
'--model',
223-
model,
224-
// Skip all confirmations.
225-
'--approval-mode',
226-
'yolo',
227-
],
228-
{
229-
cwd: context.directory,
230-
env: {...process.env},
231-
},
65+
if (context.buildCommand) {
66+
promises.push(
67+
writeFile(
68+
instructionFilePath,
69+
getGeminiInstructionsFile(context.systemInstructions, context.buildCommand),
70+
),
23271
);
72+
}
23373

234-
childProcess.on('close', code =>
235-
finalize('Gemini CLI process has exited' + (code == null ? '.' : ` with ${code} code.`)),
74+
if (context.packageManager) {
75+
writeFile(
76+
join(settingsDir, 'settings.json'),
77+
getGeminiSettingsFile(context.packageManager, context.possiblePackageManagers),
23678
);
237-
childProcess.stdout.on('data', data => {
238-
if (inactivityTimeout) {
239-
this.pendingTimeouts.delete(inactivityTimeout);
240-
clearTimeout(inactivityTimeout);
241-
}
242-
243-
stdoutBuffer += data.toString();
244-
inactivityTimeout = setTimeout(noOutputCallback, inactivityTimeoutMins * msPerMin);
245-
this.pendingTimeouts.add(inactivityTimeout);
246-
});
247-
childProcess.stderr.on('data', data => {
248-
stdErrBuffer += data.toString();
249-
});
250-
});
79+
}
25180
}
25281
}

0 commit comments

Comments
 (0)