ak-gemini 1.2.0 → 2.0.1
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 +259 -294
- package/base.js +485 -0
- package/chat.js +87 -0
- package/code-agent.js +687 -0
- package/index.cjs +1928 -1213
- package/index.js +40 -1501
- package/json-helpers.js +352 -0
- package/message.js +170 -0
- package/package.json +14 -7
- package/tool-agent.js +312 -0
- package/transformer.js +502 -0
- package/types.d.ts +452 -241
- package/agent.js +0 -481
- package/tools.js +0 -134
package/code-agent.js
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview CodeAgent class — AI agent that writes and executes code.
|
|
3
|
+
* Instead of traditional tool-calling with many round-trips, the model gets
|
|
4
|
+
* a single `execute_code` tool and writes JavaScript that can do everything
|
|
5
|
+
* (read files, write files, run commands) in a single script. Output feeds
|
|
6
|
+
* back, and the model decides what to do next.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by the "code mode" philosophy: LLMs are better at writing code
|
|
9
|
+
* to call APIs than at calling APIs directly via tool-calling.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import BaseGemini from './base.js';
|
|
13
|
+
import log from './logger.js';
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { writeFile, unlink, readdir, readFile, mkdir } from 'node:fs/promises';
|
|
16
|
+
import { join, sep } from 'node:path';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {import('./types').CodeAgentOptions} CodeAgentOptions
|
|
21
|
+
* @typedef {import('./types').CodeAgentResponse} CodeAgentResponse
|
|
22
|
+
* @typedef {import('./types').CodeAgentStreamEvent} CodeAgentStreamEvent
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const MAX_OUTPUT_CHARS = 50_000;
|
|
26
|
+
const MAX_FILE_TREE_LINES = 500;
|
|
27
|
+
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.next', 'build', '__pycache__']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* AI agent that writes and executes JavaScript code autonomously. ... what could possibly go wrong, right?
|
|
31
|
+
*
|
|
32
|
+
* During init, gathers codebase context (file tree + key files) and injects it
|
|
33
|
+
* into the system prompt. The model uses the `execute_code` tool to run scripts
|
|
34
|
+
* in a Node.js child process that inherits the parent's environment variables.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```javascript
|
|
38
|
+
* import { CodeAgent } from 'ak-gemini';
|
|
39
|
+
*
|
|
40
|
+
* const agent = new CodeAgent({
|
|
41
|
+
* workingDirectory: '/path/to/my/project',
|
|
42
|
+
* onCodeExecution: (code, output) => {
|
|
43
|
+
* console.log('Executed:', code.slice(0, 100));
|
|
44
|
+
* console.log('Output:', output.stdout);
|
|
45
|
+
* }
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* const result = await agent.chat('List all TODO comments in the codebase');
|
|
49
|
+
* console.log(result.text);
|
|
50
|
+
* console.log(`Ran ${result.codeExecutions.length} scripts`);
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
class CodeAgent extends BaseGemini {
|
|
54
|
+
/**
|
|
55
|
+
* @param {CodeAgentOptions} [options={}]
|
|
56
|
+
*/
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
if (options.systemPrompt === undefined) {
|
|
59
|
+
options = { ...options, systemPrompt: '' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
super(options);
|
|
63
|
+
|
|
64
|
+
// ── Agent config ──
|
|
65
|
+
this.workingDirectory = options.workingDirectory || process.cwd();
|
|
66
|
+
this.maxRounds = options.maxRounds || 10;
|
|
67
|
+
this.timeout = options.timeout || 30_000;
|
|
68
|
+
this.onBeforeExecution = options.onBeforeExecution || null;
|
|
69
|
+
this.onCodeExecution = options.onCodeExecution || null;
|
|
70
|
+
this.importantFiles = options.importantFiles || [];
|
|
71
|
+
this.writeDir = options.writeDir || join(this.workingDirectory, 'tmp');
|
|
72
|
+
this.keepArtifacts = options.keepArtifacts ?? false;
|
|
73
|
+
this.comments = options.comments ?? false;
|
|
74
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
75
|
+
|
|
76
|
+
// ── Internal state ──
|
|
77
|
+
this._codebaseContext = null;
|
|
78
|
+
this._contextGathered = false;
|
|
79
|
+
this._stopped = false;
|
|
80
|
+
this._activeProcess = null;
|
|
81
|
+
this._userSystemPrompt = options.systemPrompt || '';
|
|
82
|
+
this._allExecutions = [];
|
|
83
|
+
|
|
84
|
+
// ── Single tool: execute_code ──
|
|
85
|
+
this.chatConfig.tools = [{
|
|
86
|
+
functionDeclarations: [{
|
|
87
|
+
name: 'execute_code',
|
|
88
|
+
description: 'Execute JavaScript code in a Node.js child process. The code has access to all Node.js built-in modules (fs, path, child_process, http, etc.). Use console.log() to produce output that will be returned to you. The code runs in the working directory with the same environment variables as the parent process.',
|
|
89
|
+
parametersJsonSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
code: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: 'JavaScript code to execute. Use console.log() for output. You can import any built-in Node.js module.'
|
|
95
|
+
},
|
|
96
|
+
purpose: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'A short 2-4 word slug describing what this script does (e.g., "read-config", "parse-logs", "fetch-api-data"). Used for naming the script file.'
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
required: ['code']
|
|
102
|
+
}
|
|
103
|
+
}]
|
|
104
|
+
}];
|
|
105
|
+
this.chatConfig.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
|
106
|
+
|
|
107
|
+
log.debug(`CodeAgent created for directory: ${this.workingDirectory}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Init ─────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Initialize the agent: gather codebase context, build system prompt,
|
|
114
|
+
* and create the chat session.
|
|
115
|
+
* @param {boolean} [force=false]
|
|
116
|
+
*/
|
|
117
|
+
async init(force = false) {
|
|
118
|
+
if (this.chatSession && !force) return;
|
|
119
|
+
|
|
120
|
+
// Gather codebase context
|
|
121
|
+
if (!this._contextGathered || force) {
|
|
122
|
+
await this._gatherCodebaseContext();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build augmented system prompt
|
|
126
|
+
const systemPrompt = this._buildSystemPrompt();
|
|
127
|
+
this.chatConfig.systemInstruction = systemPrompt;
|
|
128
|
+
|
|
129
|
+
await super.init(force);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Context Gathering ────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Gather file tree and key file contents from the working directory.
|
|
136
|
+
* @private
|
|
137
|
+
*/
|
|
138
|
+
async _gatherCodebaseContext() {
|
|
139
|
+
let fileTree = '';
|
|
140
|
+
|
|
141
|
+
// Get file tree
|
|
142
|
+
try {
|
|
143
|
+
fileTree = await this._getFileTreeGit();
|
|
144
|
+
} catch {
|
|
145
|
+
log.debug('git ls-files failed, falling back to readdir');
|
|
146
|
+
fileTree = await this._getFileTreeReaddir(this.workingDirectory, 0, 3);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Truncate file tree
|
|
150
|
+
const lines = fileTree.split('\n');
|
|
151
|
+
if (lines.length > MAX_FILE_TREE_LINES) {
|
|
152
|
+
const truncated = lines.slice(0, MAX_FILE_TREE_LINES).join('\n');
|
|
153
|
+
fileTree = `${truncated}\n... (${lines.length - MAX_FILE_TREE_LINES} more files)`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Extract npm package names (lightweight — just the keys)
|
|
157
|
+
let npmPackages = [];
|
|
158
|
+
try {
|
|
159
|
+
const pkgPath = join(this.workingDirectory, 'package.json');
|
|
160
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
|
|
161
|
+
npmPackages = [
|
|
162
|
+
...Object.keys(pkg.dependencies || {}),
|
|
163
|
+
...Object.keys(pkg.devDependencies || {})
|
|
164
|
+
];
|
|
165
|
+
} catch { /* no package.json */ }
|
|
166
|
+
|
|
167
|
+
// Resolve and read important files
|
|
168
|
+
const importantFileContents = [];
|
|
169
|
+
if (this.importantFiles.length > 0) {
|
|
170
|
+
const fileTreeLines = fileTree.split('\n').map(l => l.trim()).filter(Boolean);
|
|
171
|
+
for (const requested of this.importantFiles) {
|
|
172
|
+
const resolved = this._resolveImportantFile(requested, fileTreeLines);
|
|
173
|
+
if (!resolved) {
|
|
174
|
+
log.warn(`importantFiles: could not locate "${requested}"`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const fullPath = join(this.workingDirectory, resolved);
|
|
179
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
180
|
+
importantFileContents.push({ path: resolved, content });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
log.warn(`importantFiles: could not read "${resolved}": ${e.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this._codebaseContext = { fileTree, npmPackages, importantFileContents };
|
|
188
|
+
this._contextGathered = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve an importantFiles entry against the file tree.
|
|
193
|
+
* Supports exact matches and partial (basename/suffix) matches.
|
|
194
|
+
* @private
|
|
195
|
+
* @param {string} filename
|
|
196
|
+
* @param {string[]} fileTreeLines
|
|
197
|
+
* @returns {string|null}
|
|
198
|
+
*/
|
|
199
|
+
_resolveImportantFile(filename, fileTreeLines) {
|
|
200
|
+
// Exact match
|
|
201
|
+
const exact = fileTreeLines.find(line => line === filename);
|
|
202
|
+
if (exact) return exact;
|
|
203
|
+
|
|
204
|
+
// Partial match — filename matches end of path
|
|
205
|
+
const partial = fileTreeLines.find(line =>
|
|
206
|
+
line.endsWith('/' + filename) || line.endsWith(sep + filename)
|
|
207
|
+
);
|
|
208
|
+
return partial || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get file tree using git ls-files.
|
|
213
|
+
* @private
|
|
214
|
+
* @returns {Promise<string>}
|
|
215
|
+
*/
|
|
216
|
+
async _getFileTreeGit() {
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
execFile('git', ['ls-files'], {
|
|
219
|
+
cwd: this.workingDirectory,
|
|
220
|
+
timeout: 5000,
|
|
221
|
+
maxBuffer: 5 * 1024 * 1024
|
|
222
|
+
}, (err, stdout) => {
|
|
223
|
+
if (err) return reject(err);
|
|
224
|
+
resolve(stdout.trim());
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Fallback file tree via recursive readdir.
|
|
231
|
+
* @private
|
|
232
|
+
* @param {string} dir
|
|
233
|
+
* @param {number} depth
|
|
234
|
+
* @param {number} maxDepth
|
|
235
|
+
* @returns {Promise<string>}
|
|
236
|
+
*/
|
|
237
|
+
async _getFileTreeReaddir(dir, depth, maxDepth) {
|
|
238
|
+
if (depth >= maxDepth) return '';
|
|
239
|
+
const entries = [];
|
|
240
|
+
try {
|
|
241
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
242
|
+
for (const item of items) {
|
|
243
|
+
if (IGNORE_DIRS.has(item.name)) continue;
|
|
244
|
+
if (item.name.startsWith('.') && depth === 0 && item.isDirectory()) continue;
|
|
245
|
+
|
|
246
|
+
const relativePath = join(dir, item.name).replace(this.workingDirectory + '/', '');
|
|
247
|
+
if (item.isFile()) {
|
|
248
|
+
entries.push(relativePath);
|
|
249
|
+
} else if (item.isDirectory()) {
|
|
250
|
+
entries.push(relativePath + '/');
|
|
251
|
+
const subEntries = await this._getFileTreeReaddir(join(dir, item.name), depth + 1, maxDepth);
|
|
252
|
+
if (subEntries) entries.push(subEntries);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Permission errors, etc. — skip
|
|
257
|
+
}
|
|
258
|
+
return entries.join('\n');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build the full system prompt with codebase context.
|
|
263
|
+
* @private
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
_buildSystemPrompt() {
|
|
267
|
+
const { fileTree, npmPackages, importantFileContents } = this._codebaseContext || { fileTree: '', npmPackages: [], importantFileContents: [] };
|
|
268
|
+
|
|
269
|
+
let prompt = `You are a coding agent working in ${this.workingDirectory}.
|
|
270
|
+
|
|
271
|
+
## Instructions
|
|
272
|
+
- Use the execute_code tool to accomplish tasks by writing JavaScript code
|
|
273
|
+
- Always provide a short descriptive \`purpose\` parameter (2-4 word slug like "read-config") when calling execute_code
|
|
274
|
+
- Your code runs in a Node.js child process with access to all built-in modules
|
|
275
|
+
- IMPORTANT: Your code runs as an ES module (.mjs). Use import syntax, NOT require():
|
|
276
|
+
- import fs from 'fs';
|
|
277
|
+
- import path from 'path';
|
|
278
|
+
- import { execSync } from 'child_process';
|
|
279
|
+
- Use console.log() to produce output — that's how results are returned to you
|
|
280
|
+
- Write efficient scripts that do multiple things per execution when possible
|
|
281
|
+
- For parallel async operations, use Promise.all():
|
|
282
|
+
const [a, b] = await Promise.all([fetchA(), fetchB()]);
|
|
283
|
+
- Read files with fs.readFileSync() when you need to understand their contents
|
|
284
|
+
- Handle errors in your scripts with try/catch so you get useful error messages
|
|
285
|
+
- Top-level await is supported
|
|
286
|
+
- The working directory is: ${this.workingDirectory}`;
|
|
287
|
+
|
|
288
|
+
if (this.comments) {
|
|
289
|
+
prompt += `\n- Add a JSDoc @fileoverview comment at the top of each script explaining what it does\n- Add brief JSDoc @param comments for any functions you define`;
|
|
290
|
+
} else {
|
|
291
|
+
prompt += `\n- Do NOT write any comments in your code — save tokens. The code should be self-explanatory.`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (fileTree) {
|
|
295
|
+
prompt += `\n\n## File Tree\n\`\`\`\n${fileTree}\n\`\`\``;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (npmPackages.length > 0) {
|
|
299
|
+
prompt += `\n\n## Available Packages\nThese npm packages are installed and can be imported: ${npmPackages.join(', ')}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (importantFileContents && importantFileContents.length > 0) {
|
|
303
|
+
prompt += `\n\n## Key Files`;
|
|
304
|
+
for (const { path: filePath, content } of importantFileContents) {
|
|
305
|
+
prompt += `\n\n### ${filePath}\n\`\`\`javascript\n${content}\n\`\`\``;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (this._userSystemPrompt) {
|
|
310
|
+
prompt += `\n\n## Additional Instructions\n${this._userSystemPrompt}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return prompt;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Code Execution ───────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Generate a sanitized slug from a purpose string.
|
|
320
|
+
* @private
|
|
321
|
+
* @param {string} [purpose]
|
|
322
|
+
* @returns {string}
|
|
323
|
+
*/
|
|
324
|
+
_slugify(purpose) {
|
|
325
|
+
if (!purpose) return randomUUID().slice(0, 8);
|
|
326
|
+
return purpose.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Execute a JavaScript code string in a child process.
|
|
331
|
+
* @private
|
|
332
|
+
* @param {string} code - JavaScript code to execute
|
|
333
|
+
* @param {string} [purpose] - Short description for file naming
|
|
334
|
+
* @returns {Promise<{stdout: string, stderr: string, exitCode: number, denied?: boolean}>}
|
|
335
|
+
*/
|
|
336
|
+
async _executeCode(code, purpose) {
|
|
337
|
+
// Check if stopped
|
|
338
|
+
if (this._stopped) {
|
|
339
|
+
return { stdout: '', stderr: 'Agent was stopped', exitCode: -1 };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Check onBeforeExecution gate
|
|
343
|
+
if (this.onBeforeExecution) {
|
|
344
|
+
try {
|
|
345
|
+
const allowed = await this.onBeforeExecution(code);
|
|
346
|
+
if (allowed === false) {
|
|
347
|
+
return { stdout: '', stderr: 'Execution denied by onBeforeExecution callback', exitCode: -1, denied: true };
|
|
348
|
+
}
|
|
349
|
+
} catch (e) {
|
|
350
|
+
log.warn(`onBeforeExecution callback error: ${e.message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Ensure writeDir exists
|
|
355
|
+
await mkdir(this.writeDir, { recursive: true });
|
|
356
|
+
|
|
357
|
+
const slug = this._slugify(purpose);
|
|
358
|
+
const tempFile = join(this.writeDir, `agent-${slug}-${Date.now()}.mjs`);
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
// Write code to temp file
|
|
362
|
+
await writeFile(tempFile, code, 'utf-8');
|
|
363
|
+
|
|
364
|
+
// Execute in child process
|
|
365
|
+
const result = await new Promise((resolve) => {
|
|
366
|
+
const child = execFile('node', [tempFile], {
|
|
367
|
+
cwd: this.workingDirectory,
|
|
368
|
+
timeout: this.timeout,
|
|
369
|
+
env: process.env,
|
|
370
|
+
maxBuffer: 10 * 1024 * 1024
|
|
371
|
+
}, (err, stdout, stderr) => {
|
|
372
|
+
this._activeProcess = null;
|
|
373
|
+
if (err) {
|
|
374
|
+
resolve({
|
|
375
|
+
stdout: err.stdout || stdout || '',
|
|
376
|
+
stderr: (err.stderr || stderr || '') + (err.killed ? '\n[EXECUTION TIMED OUT]' : ''),
|
|
377
|
+
exitCode: err.code || 1
|
|
378
|
+
});
|
|
379
|
+
} else {
|
|
380
|
+
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: 0 });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
this._activeProcess = child;
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Truncate output
|
|
387
|
+
const totalLen = result.stdout.length + result.stderr.length;
|
|
388
|
+
if (totalLen > MAX_OUTPUT_CHARS) {
|
|
389
|
+
const half = Math.floor(MAX_OUTPUT_CHARS / 2);
|
|
390
|
+
if (result.stdout.length > half) {
|
|
391
|
+
result.stdout = result.stdout.slice(0, half) + '\n...[OUTPUT TRUNCATED]';
|
|
392
|
+
}
|
|
393
|
+
if (result.stderr.length > half) {
|
|
394
|
+
result.stderr = result.stderr.slice(0, half) + '\n...[STDERR TRUNCATED]';
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Track execution
|
|
399
|
+
this._allExecutions.push({
|
|
400
|
+
code, purpose: purpose || null, output: result.stdout, stderr: result.stderr,
|
|
401
|
+
exitCode: result.exitCode, filePath: this.keepArtifacts ? tempFile : null
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Fire notification callback
|
|
405
|
+
if (this.onCodeExecution) {
|
|
406
|
+
try { this.onCodeExecution(code, result); }
|
|
407
|
+
catch (e) { log.warn(`onCodeExecution callback error: ${e.message}`); }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return result;
|
|
411
|
+
} finally {
|
|
412
|
+
// Cleanup temp file (unless keeping artifacts)
|
|
413
|
+
if (!this.keepArtifacts) {
|
|
414
|
+
try { await unlink(tempFile); }
|
|
415
|
+
catch { /* file may already be gone */ }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Format execution result as a string for the model.
|
|
422
|
+
* @private
|
|
423
|
+
* @param {{stdout: string, stderr: string, exitCode: number}} result
|
|
424
|
+
* @returns {string}
|
|
425
|
+
*/
|
|
426
|
+
_formatOutput(result) {
|
|
427
|
+
let output = '';
|
|
428
|
+
if (result.stdout) output += result.stdout;
|
|
429
|
+
if (result.stderr) output += (output ? '\n' : '') + `[STDERR]: ${result.stderr}`;
|
|
430
|
+
if (result.exitCode !== 0) output += (output ? '\n' : '') + `[EXIT CODE]: ${result.exitCode}`;
|
|
431
|
+
return output || '(no output)';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Non-Streaming Chat ───────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Send a message and get a complete response (non-streaming).
|
|
438
|
+
* Automatically handles the code execution loop.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} message - The user's message
|
|
441
|
+
* @param {Object} [opts={}] - Per-message options
|
|
442
|
+
* @param {Record<string, string>} [opts.labels] - Per-message billing labels
|
|
443
|
+
* @returns {Promise<CodeAgentResponse>} Response with text, codeExecutions, and usage
|
|
444
|
+
*/
|
|
445
|
+
async chat(message, opts = {}) {
|
|
446
|
+
if (!this.chatSession) await this.init();
|
|
447
|
+
this._stopped = false;
|
|
448
|
+
|
|
449
|
+
const codeExecutions = [];
|
|
450
|
+
let consecutiveFailures = 0;
|
|
451
|
+
|
|
452
|
+
let response = await this.chatSession.sendMessage({ message });
|
|
453
|
+
|
|
454
|
+
for (let round = 0; round < this.maxRounds; round++) {
|
|
455
|
+
if (this._stopped) break;
|
|
456
|
+
|
|
457
|
+
const functionCalls = response.functionCalls;
|
|
458
|
+
if (!functionCalls || functionCalls.length === 0) break;
|
|
459
|
+
|
|
460
|
+
const results = [];
|
|
461
|
+
for (const call of functionCalls) {
|
|
462
|
+
if (this._stopped) break;
|
|
463
|
+
|
|
464
|
+
const code = call.args?.code || '';
|
|
465
|
+
const purpose = call.args?.purpose;
|
|
466
|
+
const result = await this._executeCode(code, purpose);
|
|
467
|
+
|
|
468
|
+
codeExecutions.push({
|
|
469
|
+
code,
|
|
470
|
+
purpose: this._slugify(purpose),
|
|
471
|
+
output: result.stdout,
|
|
472
|
+
stderr: result.stderr,
|
|
473
|
+
exitCode: result.exitCode
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
if (result.exitCode !== 0 && !result.denied) {
|
|
477
|
+
consecutiveFailures++;
|
|
478
|
+
} else {
|
|
479
|
+
consecutiveFailures = 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let output = this._formatOutput(result);
|
|
483
|
+
|
|
484
|
+
if (consecutiveFailures >= this.maxRetries) {
|
|
485
|
+
output += `\n\n[RETRY LIMIT REACHED] You have failed ${this.maxRetries} consecutive attempts. STOP trying to execute code. Instead, respond with: 1) What you were trying to do, 2) The errors you encountered, 3) Questions for the user about how to resolve it.`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
results.push({
|
|
489
|
+
id: call.id,
|
|
490
|
+
name: call.name,
|
|
491
|
+
result: output
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (this._stopped) break;
|
|
496
|
+
|
|
497
|
+
// Send function responses back to the model
|
|
498
|
+
response = await this.chatSession.sendMessage({
|
|
499
|
+
message: results.map(r => ({
|
|
500
|
+
functionResponse: {
|
|
501
|
+
id: r.id,
|
|
502
|
+
name: r.name,
|
|
503
|
+
response: { output: r.result }
|
|
504
|
+
}
|
|
505
|
+
}))
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
if (consecutiveFailures >= this.maxRetries) break;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
this._captureMetadata(response);
|
|
512
|
+
|
|
513
|
+
this._cumulativeUsage = {
|
|
514
|
+
promptTokens: this.lastResponseMetadata.promptTokens,
|
|
515
|
+
responseTokens: this.lastResponseMetadata.responseTokens,
|
|
516
|
+
totalTokens: this.lastResponseMetadata.totalTokens,
|
|
517
|
+
attempts: 1
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
text: response.text || '',
|
|
522
|
+
codeExecutions,
|
|
523
|
+
usage: this.getLastUsage()
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── Streaming ────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Send a message and stream the response as events.
|
|
531
|
+
* Automatically handles the code execution loop between streamed rounds.
|
|
532
|
+
*
|
|
533
|
+
* Event types:
|
|
534
|
+
* - `text` — A chunk of the agent's text response
|
|
535
|
+
* - `code` — The agent is about to execute code
|
|
536
|
+
* - `output` — Code finished executing
|
|
537
|
+
* - `done` — The agent finished
|
|
538
|
+
*
|
|
539
|
+
* @param {string} message - The user's message
|
|
540
|
+
* @param {Object} [opts={}] - Per-message options
|
|
541
|
+
* @yields {CodeAgentStreamEvent}
|
|
542
|
+
*/
|
|
543
|
+
async *stream(message, opts = {}) {
|
|
544
|
+
if (!this.chatSession) await this.init();
|
|
545
|
+
this._stopped = false;
|
|
546
|
+
|
|
547
|
+
const codeExecutions = [];
|
|
548
|
+
let fullText = '';
|
|
549
|
+
let consecutiveFailures = 0;
|
|
550
|
+
|
|
551
|
+
let streamResponse = await this.chatSession.sendMessageStream({ message });
|
|
552
|
+
|
|
553
|
+
for (let round = 0; round < this.maxRounds; round++) {
|
|
554
|
+
if (this._stopped) break;
|
|
555
|
+
|
|
556
|
+
const functionCalls = [];
|
|
557
|
+
|
|
558
|
+
// Consume the stream
|
|
559
|
+
for await (const chunk of streamResponse) {
|
|
560
|
+
if (chunk.functionCalls) {
|
|
561
|
+
functionCalls.push(...chunk.functionCalls);
|
|
562
|
+
} else if (chunk.candidates?.[0]?.content?.parts?.[0]?.text) {
|
|
563
|
+
const text = chunk.candidates[0].content.parts[0].text;
|
|
564
|
+
fullText += text;
|
|
565
|
+
yield { type: 'text', text };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// No function calls — we're done
|
|
570
|
+
if (functionCalls.length === 0) {
|
|
571
|
+
yield {
|
|
572
|
+
type: 'done',
|
|
573
|
+
fullText,
|
|
574
|
+
codeExecutions,
|
|
575
|
+
usage: this.getLastUsage()
|
|
576
|
+
};
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Execute code sequentially so we can yield events
|
|
581
|
+
const results = [];
|
|
582
|
+
for (const call of functionCalls) {
|
|
583
|
+
if (this._stopped) break;
|
|
584
|
+
|
|
585
|
+
const code = call.args?.code || '';
|
|
586
|
+
const purpose = call.args?.purpose;
|
|
587
|
+
yield { type: 'code', code };
|
|
588
|
+
|
|
589
|
+
const result = await this._executeCode(code, purpose);
|
|
590
|
+
|
|
591
|
+
codeExecutions.push({
|
|
592
|
+
code,
|
|
593
|
+
purpose: this._slugify(purpose),
|
|
594
|
+
output: result.stdout,
|
|
595
|
+
stderr: result.stderr,
|
|
596
|
+
exitCode: result.exitCode
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
yield {
|
|
600
|
+
type: 'output',
|
|
601
|
+
code,
|
|
602
|
+
stdout: result.stdout,
|
|
603
|
+
stderr: result.stderr,
|
|
604
|
+
exitCode: result.exitCode
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
if (result.exitCode !== 0 && !result.denied) {
|
|
608
|
+
consecutiveFailures++;
|
|
609
|
+
} else {
|
|
610
|
+
consecutiveFailures = 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let output = this._formatOutput(result);
|
|
614
|
+
|
|
615
|
+
if (consecutiveFailures >= this.maxRetries) {
|
|
616
|
+
output += `\n\n[RETRY LIMIT REACHED] You have failed ${this.maxRetries} consecutive attempts. STOP trying to execute code. Instead, respond with: 1) What you were trying to do, 2) The errors you encountered, 3) Questions for the user about how to resolve it.`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
results.push({
|
|
620
|
+
id: call.id,
|
|
621
|
+
name: call.name,
|
|
622
|
+
result: output
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (this._stopped) break;
|
|
627
|
+
|
|
628
|
+
// Send function responses back and get next stream
|
|
629
|
+
streamResponse = await this.chatSession.sendMessageStream({
|
|
630
|
+
message: results.map(r => ({
|
|
631
|
+
functionResponse: {
|
|
632
|
+
id: r.id,
|
|
633
|
+
name: r.name,
|
|
634
|
+
response: { output: r.result }
|
|
635
|
+
}
|
|
636
|
+
}))
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
if (consecutiveFailures >= this.maxRetries) break;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Max rounds reached, stopped, or retry limit hit
|
|
643
|
+
let warning = 'Max tool rounds reached';
|
|
644
|
+
if (this._stopped) warning = 'Agent was stopped';
|
|
645
|
+
else if (consecutiveFailures >= this.maxRetries) warning = 'Retry limit reached';
|
|
646
|
+
|
|
647
|
+
yield {
|
|
648
|
+
type: 'done',
|
|
649
|
+
fullText,
|
|
650
|
+
codeExecutions,
|
|
651
|
+
usage: this.getLastUsage(),
|
|
652
|
+
warning
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── Dump ─────────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Returns all code scripts the agent has written across all chat/stream calls.
|
|
660
|
+
* @returns {Array<{fileName: string, script: string}>}
|
|
661
|
+
*/
|
|
662
|
+
dump() {
|
|
663
|
+
return this._allExecutions.map((exec, i) => ({
|
|
664
|
+
fileName: exec.purpose ? `agent-${exec.purpose}.mjs` : `script-${i + 1}.mjs`,
|
|
665
|
+
purpose: exec.purpose || null,
|
|
666
|
+
script: exec.code,
|
|
667
|
+
filePath: exec.filePath || null
|
|
668
|
+
}));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── Stop ─────────────────────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Stop the agent before the next code execution.
|
|
675
|
+
* If a child process is currently running, it will be killed.
|
|
676
|
+
*/
|
|
677
|
+
stop() {
|
|
678
|
+
this._stopped = true;
|
|
679
|
+
if (this._activeProcess) {
|
|
680
|
+
try { this._activeProcess.kill('SIGTERM'); }
|
|
681
|
+
catch { /* process may already be gone */ }
|
|
682
|
+
}
|
|
683
|
+
log.info('CodeAgent stopped');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export default CodeAgent;
|