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/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;