@orxataguy/tyr 1.0.31 → 1.0.32

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.
@@ -1,12 +1,16 @@
1
1
  import path from 'path';
2
+ import { statSync } from 'fs';
2
3
 
3
4
  import { FileSystemManager } from './FileSystemManager.js';
4
- import { AIVendorManager, AIMessage } from './AIVendorManager.js';
5
+ import { ShellManager } from './ShellManager.js';
6
+ import { AIVendorManager, AIMessage, AIContentBlock, AITool, TaskPriority } from './AIVendorManager.js';
7
+ import { TokenManager } from './TokenManager.js';
5
8
  import { Logger } from '../core/Logger.js';
6
9
  import { TyrError } from '../core/TyrError.js';
7
10
 
8
11
  import { getEnvInt } from '../core/util/getenv.js';
9
12
 
13
+ // --- Context files (CLAUDE.md / AGENTS.md style guidelines) --------------------------------------
10
14
 
11
15
  const CONTEXT_FILENAMES = [
12
16
  'CLAUDE.md',
@@ -18,7 +22,7 @@ const CONTEXT_FILENAMES = [
18
22
 
19
23
  const GENERATED_FILENAME = 'CLAUDE.md';
20
24
 
21
- const SNAPSHOT_TREE_MAX_DEPTH = getEnvInt('SNAPSHOT_TREE_MAX_DEPTH', 5);
25
+ const SNAPSHOT_TREE_MAX_DEPTH = getEnvInt('SNAPSHOT_TREE_MAX_DEPTH', 5);
22
26
  const SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR = getEnvInt('SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR', 250);
23
27
  const SNAPSHOT_SECTION_MAX_CHARS = getEnvInt('SNAPSHOT_SECTION_MAX_CHARS', 40000);
24
28
 
@@ -44,25 +48,271 @@ interface GuidelinesBlock {
44
48
  content: string;
45
49
  }
46
50
 
51
+ // --- Agentic exploration (tool use) ---------------------------------------------------------------
52
+ //
53
+ // Everything below powers the tool-use agent loop shared by ai:code and ai:describe: the model is
54
+ // seeded with only a tree + AGENTS.md, and pulls in anything else (a specific file, a directory
55
+ // listing, a dependency's real source) itself via these tools, instead of the command layer trying
56
+ // to guess what's relevant ahead of time (see runAgentLoop / AGENT_TOOLS).
57
+
58
+ const AGENTS_MD_FILENAME = 'AGENTS.md';
59
+ const MANIFEST_FILENAMES = new Set(['package.json', 'composer.json']);
60
+ const README_PATTERN = /^readme(\.[a-z0-9]+)?$/i;
61
+
62
+ const READABLE_EXTENSIONS = new Set([
63
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
64
+ '.json', '.md', '.yml', '.yaml',
65
+ '.py', '.go', '.rb', '.php', '.java', '.rs',
66
+ '.sh',
67
+ ]);
68
+
69
+ const TREE_MAX_ENTRIES_PER_DIR = 200;
70
+
71
+ // How many levels upward from the starting directory we search for a repo root (marked by a .git
72
+ // folder) to decide how far the model's READ-ONLY exploration tools can reach. Does not affect
73
+ // where WRITES are allowed — that stays confined to the target directory (see applyPatches).
74
+ const MAX_UPWARD_SEARCH_LEVELS = 4;
75
+
76
+ const MAX_TOOL_RESULT_CHARS = 12000;
77
+ const MAX_DEPENDENCY_MATCHES = 25;
78
+ const DEFAULT_MAX_AGENT_ITERATIONS = 6;
79
+
80
+ // Deliberately more lenient than DEFAULT_IGNORED_DIRS when listing inside an installed dependency:
81
+ // "dist/build" is generated noise in the project itself, but it's exactly the real, compiled code
82
+ // that needs reading inside node_modules.
83
+ const PACKAGE_IGNORED_DIRS = new Set<string>(['node_modules', '.git', '.hg', '.svn', '.github', 'coverage', '.nyc_output']);
84
+
85
+ // How many levels upward, from the project directory, we search for node_modules/<package>.
86
+ // Deliberately more generous than MAX_UPWARD_SEARCH_LEVELS: mimics real Node.js module
87
+ // resolution, which can land above the repo root in hoisted monorepos.
88
+ const NODE_MODULES_SEARCH_MAX_LEVELS = 15;
89
+
90
+ // Loop Detector: abort the agent loop if the same tool is called with identical arguments this
91
+ // many times in a row, to avoid infinite loops and token draining.
92
+ const LOOP_DETECTOR_REPEAT_THRESHOLD = 3;
93
+
94
+ // Post-write validation / self-healing: how many extra agent turns we allow to fix a broken
95
+ // compile/lint before giving up and returning whatever was last applied.
96
+ const MAX_SELF_HEAL_ATTEMPTS = 2;
97
+
98
+ /** Shared phrase describing the exploration tools, appended to every agentic system prompt. */
99
+ export const AGENT_TOOLS_DESCRIPTION =
100
+ 'You have tools available to request additional context that is not already included in the ' +
101
+ 'message: use read_manifest to learn the project\'s real dependencies (npm/composer) and ' +
102
+ 'search_dependency_usage to see how relevant dependencies are actually used; use find_agents_md ' +
103
+ 'to locate existing context documentation in subfolders or sibling packages of a monorepo, and ' +
104
+ 'read_file to read it or any other existing relevant file that has not already been shown to you ' +
105
+ '(for example, one omitted for size); use list_directory if you need to see the contents of a ' +
106
+ 'folder not included in the tree. If the project uses an installed framework or library (for ' +
107
+ 'example, something AGENTS.md only names without detailing its API) and you need to know exactly ' +
108
+ 'how it works before using it, do NOT assume its behaviour: use read_dependency_manifest to ' +
109
+ 'locate it in node_modules and see its entry points, then list_dependency_files / ' +
110
+ 'read_dependency_file to inspect its real code. Call whatever tools you need, as many times as ' +
111
+ 'you need, before answering.';
112
+
113
+ /** Git-like Search/Replace patch format the model must use instead of full-file rewrites, shared
114
+ * by every prompt that may end up calling AIContextManager.runCodeAgent(). */
115
+ export const SEARCH_REPLACE_FORMAT_INSTRUCTIONS =
116
+ 'For every file you create or modify, output it using EXACTLY this format, with no other text ' +
117
+ 'before, between or after the blocks:\n\n' +
118
+ '>>>FILE: <path relative to the project root>\n' +
119
+ '<<<<<<< SEARCH\n' +
120
+ '<exact existing code snippet to find — leave EMPTY only when creating a brand-new file>\n' +
121
+ '=======\n' +
122
+ '<the new replacement code snippet>\n' +
123
+ '>>>>>>> REPLACE\n' +
124
+ '<<<END\n\n' +
125
+ 'A single >>>FILE block may contain more than one SEARCH/REPLACE pair if you need to make ' +
126
+ 'several separate edits to the same file. Never output the file\'s full content — only the ' +
127
+ 'minimal snippets that need to change. The SEARCH snippet must match real, existing text from ' +
128
+ 'the file (read it with read_file first if you have not already seen its exact current content ' +
129
+ 'in this conversation); do not guess it. To create a new file, use a single block with an empty ' +
130
+ 'SEARCH section and the full new file content as REPLACE. When you have everything you need, ' +
131
+ 'respond ONLY with the >>>FILE / <<<END blocks for the files you create or modify — no other ' +
132
+ 'commentary.';
133
+
134
+ interface AgentToolContext {
135
+ /** Starting directory (targetDir in ai:code, projectDir/cwd in ai:describe), BEFORE widening
136
+ * it with computeExplorationRoot(). Used to locate node_modules the way Node.js itself would
137
+ * (searching upward from here), rather than from explorationRoot which may sit higher up. */
138
+ projectDir: string;
139
+ explorationRoot: string;
140
+ ignoredDirs: Set<string>;
141
+ /** Absolute paths of files whose FULL (untruncated) content the model has seen this session,
142
+ * either because they were included whole in the seed message or read in full via read_file.
143
+ * This is the basis of the "never write blind" guarantee: applyPatches() refuses to overwrite
144
+ * any existing file that is not in this set. */
145
+ seenFiles: Set<string>;
146
+ }
147
+
148
+ export const AGENT_TOOLS: AITool[] = [
149
+ {
150
+ name: 'list_directory',
151
+ description:
152
+ 'Lists the immediate files and subdirectories of a path within the project\'s explorable ' +
153
+ 'area. Useful for deciding whether to look deeper into a folder not already in the tree.',
154
+ input_schema: {
155
+ type: 'object',
156
+ properties: { path: { type: 'string', description: 'Path relative to the explorable area, or "." for the root.' } },
157
+ required: ['path'],
158
+ },
159
+ },
160
+ {
161
+ name: 'read_file',
162
+ description:
163
+ 'Reads the full content of a text file within the explorable area. Use it for files ' +
164
+ 'omitted from the seed message for size, or outside the original directory but relevant ' +
165
+ '(e.g. a sibling package\'s AGENTS.md in a monorepo, or an existing file you need to see ' +
166
+ 'before modifying it).',
167
+ input_schema: {
168
+ type: 'object',
169
+ properties: { path: { type: 'string', description: 'Path relative to the explorable area.' } },
170
+ required: ['path'],
171
+ },
172
+ },
173
+ {
174
+ name: 'find_agents_md',
175
+ description:
176
+ 'Finds every existing AGENTS.md file within the explorable area (including subfolders ' +
177
+ 'and, for a monorepo, sibling packages). Returns their relative paths; use read_file to ' +
178
+ 'read the one you care about.',
179
+ input_schema: { type: 'object', properties: {} },
180
+ },
181
+ {
182
+ name: 'read_manifest',
183
+ description:
184
+ 'Finds and summarises every package.json / composer.json in the explorable area: name, ' +
185
+ 'version, workspaces/autoload and dependency lists (production and development). Useful ' +
186
+ 'to know what libraries the project uses before documenting or using them.',
187
+ input_schema: { type: 'object', properties: {} },
188
+ },
189
+ {
190
+ name: 'search_dependency_usage',
191
+ description:
192
+ 'Searches the source code for import/require/use lines referencing a specific dependency ' +
193
+ '(by its package name), to understand how and where it is actually used in the project.',
194
+ input_schema: {
195
+ type: 'object',
196
+ properties: { dependency: { type: 'string', description: 'Package name as it appears in package.json or composer.json.' } },
197
+ required: ['dependency'],
198
+ },
199
+ },
200
+ {
201
+ name: 'read_dependency_manifest',
202
+ description:
203
+ 'Locates an installed dependency in node_modules (by its exact package name, e.g. ' +
204
+ '"@orxataguy/tyr" or "react") and summarises its own package.json: version, description ' +
205
+ 'and entry points (main, module, types, exports, bin). The recommended first step before ' +
206
+ 'exploring a dependency\'s code.',
207
+ input_schema: {
208
+ type: 'object',
209
+ properties: { packageName: { type: 'string', description: 'Exact package name, with scope if any (e.g. "@orxataguy/tyr").' } },
210
+ required: ['packageName'],
211
+ },
212
+ },
213
+ {
214
+ name: 'list_dependency_files',
215
+ description:
216
+ 'Lists the files and subdirectories of a folder inside an installed dependency in ' +
217
+ 'node_modules. Unlike list_directory (for the project itself), this DOES show folders ' +
218
+ 'like dist/build, since in an installed dependency that is usually the real code to inspect.',
219
+ input_schema: {
220
+ type: 'object',
221
+ properties: {
222
+ packageName: { type: 'string', description: 'Exact package name, with scope if any.' },
223
+ path: { type: 'string', description: 'Path relative to the package root, or "." for its root.' },
224
+ },
225
+ required: ['packageName'],
226
+ },
227
+ },
228
+ {
229
+ name: 'read_dependency_file',
230
+ description:
231
+ 'Reads the full content of a file inside an installed dependency in node_modules. Use it ' +
232
+ 'to understand how a library or framework actually works (types, public API, ' +
233
+ 'implementation) instead of assuming its behaviour from the package name or from what ' +
234
+ 'AGENTS.md says.',
235
+ input_schema: {
236
+ type: 'object',
237
+ properties: {
238
+ packageName: { type: 'string', description: 'Exact package name, with scope if any.' },
239
+ path: { type: 'string', description: 'Path of the file relative to the package root (use list_dependency_files or read_dependency_manifest to locate it).' },
240
+ },
241
+ required: ['packageName', 'path'],
242
+ },
243
+ },
244
+ ];
245
+
246
+ export interface AgentRunOptions {
247
+ priority?: TaskPriority;
248
+ maxIterations?: number;
249
+ maxTokens?: number;
250
+ }
251
+
252
+ export interface AgentRunResult {
253
+ content: string;
254
+ promptTokens: number;
255
+ completionTokens: number;
256
+ toolCallsUsed: number;
257
+ priorityUsed: TaskPriority;
258
+ }
259
+
260
+ export interface ValidationResult {
261
+ ran: boolean;
262
+ ok: boolean;
263
+ output?: string;
264
+ }
265
+
266
+ export interface CodeAgentResult extends AgentRunResult {
267
+ filesChanged: string[];
268
+ blockedWrites: string[];
269
+ failedEdits: string[];
270
+ validation: ValidationResult;
271
+ }
272
+
273
+ interface FilePatchEdit {
274
+ search: string;
275
+ replace: string;
276
+ }
277
+
278
+ interface FilePatchBlock {
279
+ relPath: string;
280
+ edits: FilePatchEdit[];
281
+ }
282
+
283
+ const FILE_BLOCK_REGEX = />>>FILE:\s*(.+?)\s*\n([\s\S]*?)\n<<<END/g;
284
+ const SEARCH_REPLACE_REGEX = /<<<<<<<\s*SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n>>>>>>>\s*REPLACE/g;
285
+
47
286
  /**
48
287
  * @class AIContextManager
49
- * @description Encuentra, valida y lee los archivos de directrices del proyecto (CLAUDE.md,
50
- * AGENTS.md, etc.). Si no existen, escanea el proyecto (100% Node.js nativo, sin subprocesos de
51
- * shell) y le pide al vendor de IA configurado que genere un archivo de directrices óptimo.
52
- * Expone tanto el texto plano de las directrices (para sesiones de chat que gestionan su propia
53
- * ventana de contexto) como un mensaje de sistema listo para usar (para completions puntuales).
288
+ * @description Owns everything an AI coding assistant needs to safely operate on a project:
289
+ * finding/generating guideline files (CLAUDE.md/AGENTS.md), and the bulk of this class running
290
+ * a tool-using agent loop that starts from a minimal seed (project tree + guidelines) and lets the
291
+ * model pull in whatever else it needs (a file, a directory listing, a dependency's real source)
292
+ * via AGENT_TOOLS. It also owns the Search/Replace patch engine used to apply the model's edits
293
+ * (instead of full-file rewrites), a Loop Detector that aborts runaway tool-call loops, and a
294
+ * post-write validation step that feeds compiler/linter errors back to the model and escalates the
295
+ * routing priority (see AIVendorManager.bumpPriority) when a fix attempt breaks the build.
296
+ *
297
+ * Commands built on top of this manager (ai:code, ai:describe) should stay thin: resolve paths,
298
+ * build the seed messages via PromptTemplateManager + buildExplorationTree()/getContext(), call
299
+ * runCodeAgent()/runDescribeAgent(), and log the result.
54
300
  */
55
301
  export class AIContextManager {
56
302
  private fs: FileSystemManager;
303
+ private shell: ShellManager;
57
304
  private ai: AIVendorManager;
58
305
  private logger: Logger;
59
306
 
60
- constructor(fs: FileSystemManager, ai: AIVendorManager, logger: Logger) {
307
+ constructor(fs: FileSystemManager, shell: ShellManager, ai: AIVendorManager, logger: Logger) {
61
308
  this.fs = fs;
309
+ this.shell = shell;
62
310
  this.ai = ai;
63
311
  this.logger = logger;
64
312
  }
65
313
 
314
+ // === Guideline files (CLAUDE.md / AGENTS.md) =================================================
315
+
66
316
  /**
67
317
  * @method findContextFiles
68
318
  * @description Busca los archivos de directrices conocidos directamente dentro del directorio
@@ -114,9 +364,9 @@ export class AIContextManager {
114
364
  /**
115
365
  * @method scanDirectoryTree
116
366
  * @description Escaneo recursivo de directorios agnóstico al SO usando la API nativa de
117
- * Node.js (sin subprocesos de shell, por lo que funciona igual en Windows, macOS y Linux).
118
- * Genera un árbol de texto indentado, ignora directorios de ruido y limita las entradas por
119
- * carpeta para que directorios con cientos de archivos generados no colapsen el snapshot.
367
+ * Node.js (sin subprocesos de shell), acotado en profundidad pensado para el snapshot que
368
+ * alimenta la generación de directrices. Para el árbol usado como semilla del agente (sin tope
369
+ * de profundidad), ver buildExplorationTree().
120
370
  * @param {string} rootDir - Ruta absoluta desde la que empezar a escanear.
121
371
  * @param {number} maxDepth - Profundidad máxima de recursión.
122
372
  * @param {number} maxEntriesPerDir - Máximo de entradas listadas por carpeta antes de truncar.
@@ -138,8 +388,6 @@ export class AIContextManager {
138
388
  try {
139
389
  entries = await this.fs.readdir(currentDir, { withFileTypes: true });
140
390
  } catch {
141
- // Errores de permisos o carpetas borradas a mitad de escaneo no deben abortar
142
- // todo el snapshot, simplemente se omiten.
143
391
  return;
144
392
  }
145
393
 
@@ -184,7 +432,6 @@ export class AIContextManager {
184
432
  const readmePath = path.join(dir, 'README.md');
185
433
  if (this.fs.exists(readmePath)) {
186
434
  const readme = await this.fs.read(readmePath);
187
- // El README es texto libre, no estructurado: aquí el truncado por longitud sí es seguro.
188
435
  if (readme) parts.push(`# README.md\n${readme.slice(0, SNAPSHOT_SECTION_MAX_CHARS)}`);
189
436
  }
190
437
 
@@ -231,11 +478,7 @@ export class AIContextManager {
231
478
  /**
232
479
  * @method readGuidelines
233
480
  * @description Encuentra los archivos de directrices existentes (generando uno vía IA si no
234
- * hay ninguno), los lee y valida, y los devuelve como bloques discretos etiquetados — sin
235
- * colapsarlos en un único string de mensaje de sistema. Esta es la pieza clave para sesiones
236
- * de chat de larga duración: quien necesite gestionar una ventana de contexto continua (p. ej.
237
- * un AIChatSessionManager) puede inspeccionar, cachear o descartar bloques individuales en
238
- * lugar de releer disco y reinyectar un system prompt duplicado en cada turno.
481
+ * hay ninguno), los lee y valida, y los devuelve como bloques discretos etiquetados.
239
482
  * @param {string} dir - Ruta absoluta a la raíz del proyecto.
240
483
  * @returns {Promise<GuidelinesBlock[]>}
241
484
  */
@@ -267,10 +510,7 @@ export class AIContextManager {
267
510
  /**
268
511
  * @method getGuidelinesText
269
512
  * @description Envoltorio de conveniencia sobre readGuidelines() que devuelve el texto plano
270
- * combinado de las directrices, sin envolver en roles/mensajes. Pensado para un
271
- * AIChatSessionManager: se obtiene una vez por sesión, se cachea, y se gestiona dentro de la
272
- * ventana de contexto de la conversación en lugar de llamar a getContext() (con su relectura
273
- * de disco y reenvío de un system message completo) en cada turno.
513
+ * combinado de las directrices, sin envolver en roles/mensajes.
274
514
  * @param {string} dir - Ruta absoluta a la raíz del proyecto.
275
515
  * @returns {Promise<string>}
276
516
  */
@@ -282,9 +522,10 @@ export class AIContextManager {
282
522
  /**
283
523
  * @method getContext
284
524
  * @description Punto de entrada para uso puntual: encuentra/genera archivos de directrices y
285
- * los empaqueta como un único mensaje 'system' listo para prepender a un prompt. Para sesiones
286
- * de chat continuas, usa getGuidelinesText() junto con tu propia gestión de ventana de contexto
287
- * en lugar de llamar a este método en cada turno.
525
+ * los empaqueta como un único mensaje 'system' listo para prepender a un prompt. Es también la
526
+ * pieza clave de la guía de exploración dinámica del agente: junto con buildExplorationTree(),
527
+ * es lo ÚNICO con lo que se siembra el contexto inicial de runCodeAgent()/runDescribeAgent() —
528
+ * el resto lo pide el propio modelo con AGENT_TOOLS.
288
529
  * @param {string} dir - Ruta absoluta a la raíz del proyecto.
289
530
  * @returns {Promise<AIMessage[]>}
290
531
  * @example
@@ -295,8 +536,744 @@ export class AIContextManager {
295
536
  const text = await this.getGuidelinesText(dir);
296
537
  return [{ role: 'system', content: text }];
297
538
  }
539
+
540
+ // === Exploration tree (agent seed) ============================================================
541
+
542
+ private isDirectorySync(target: string): boolean {
543
+ try {
544
+ return statSync(target).isDirectory();
545
+ } catch {
546
+ return false;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * @method buildExplorationTree
552
+ * @description Full-depth directory tree (only capped by entries-per-folder, not depth),
553
+ * used to seed the agent loop. Unlike scanDirectoryTree() (capped depth, used for guideline
554
+ * generation), the agent can always ask for more with list_directory, so this favours breadth
555
+ * over an artificial depth limit.
556
+ * @param {string} rootDir - Absolute path to start scanning from.
557
+ * @param {Set<string>} ignoredDirs - Directory names to skip entirely.
558
+ * @param {number} maxEntriesPerDir - Max entries listed per folder before truncating.
559
+ * @returns {Promise<string>} Indented text tree.
560
+ * @example
561
+ * const tree = await aiContext.buildExplorationTree(targetDir);
562
+ */
563
+ public async buildExplorationTree(
564
+ rootDir: string,
565
+ ignoredDirs: Set<string> = DEFAULT_IGNORED_DIRS,
566
+ maxEntriesPerDir: number = TREE_MAX_ENTRIES_PER_DIR
567
+ ): Promise<string> {
568
+ const lines: string[] = [`${path.basename(rootDir) || rootDir}/`];
569
+
570
+ const walk = async (dir: string, prefix: string): Promise<void> => {
571
+ let entries = await this.fs.readdir(dir, { withFileTypes: true });
572
+
573
+ entries = entries
574
+ .filter(entry => !(entry.isDirectory() && ignoredDirs.has(entry.name)))
575
+ .sort((a, b) => {
576
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
577
+ return a.name.localeCompare(b.name);
578
+ });
579
+
580
+ const visible = entries.slice(0, maxEntriesPerDir);
581
+ const hiddenCount = entries.length - visible.length;
582
+
583
+ for (const entry of visible) {
584
+ lines.push(`${prefix}${entry.name}${entry.isDirectory() ? '/' : ''}`);
585
+ if (entry.isDirectory()) {
586
+ await walk(path.join(dir, entry.name), `${prefix} `);
587
+ }
588
+ }
589
+
590
+ if (hiddenCount > 0) {
591
+ lines.push(`${prefix}… and ${hiddenCount} more item(s)`);
592
+ }
593
+ };
594
+
595
+ await walk(rootDir, ' ');
596
+ return lines.join('\n');
597
+ }
598
+
599
+ /**
600
+ * @method findReadme
601
+ * @description Finds the project's README (any common extension) and returns its name and
602
+ * content, or null if there isn't one / it's empty.
603
+ * @param {string} dir - Absolute path to the project root.
604
+ * @returns {Promise<{name: string; content: string} | null>}
605
+ * @example
606
+ * const readme = await aiContext.findReadme(projectDir);
607
+ */
608
+ public async findReadme(dir: string): Promise<{ name: string; content: string } | null> {
609
+ const entries = await this.fs.readdir(dir, { withFileTypes: true });
610
+ const match = entries.find(entry => entry.isFile() && README_PATTERN.test(entry.name));
611
+ if (!match) return null;
612
+
613
+ const content = await this.fs.read(path.join(dir, match.name));
614
+ if (!content || !content.trim()) return null;
615
+
616
+ return { name: match.name, content };
617
+ }
618
+
619
+ // === Exploration tools (tool use) =============================================================
620
+
621
+ private computeExplorationRoot(startDir: string, maxUpwardLevels: number = MAX_UPWARD_SEARCH_LEVELS): string {
622
+ let dir = startDir;
623
+ for (let i = 0; i < maxUpwardLevels; i++) {
624
+ if (this.fs.exists(path.join(dir, '.git'))) return dir;
625
+ const parent = path.dirname(dir);
626
+ if (parent === dir) break;
627
+ dir = parent;
628
+ }
629
+ return startDir;
630
+ }
631
+
632
+ private resolveSafePath(explorationRoot: string, requested: string): string {
633
+ const target = requested && requested.trim() ? requested.trim() : '.';
634
+ const resolved = path.resolve(explorationRoot, target);
635
+ const rel = path.relative(explorationRoot, resolved);
636
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
637
+ throw new TyrError(`Path outside the allowed area: ${requested}`);
638
+ }
639
+ return resolved;
640
+ }
641
+
642
+ /** Mirrors Node.js module resolution: walks up from startDir looking for node_modules/<pkg>,
643
+ * not limited to the explorable area, since hoisted monorepos can place it above the repo
644
+ * root. Read-only and strictly scoped to "node_modules/<that exact package>", so this is not
645
+ * a sandbox escape even though it can walk above the repo. */
646
+ private resolveDependencyDir(startDir: string, packageName: string): string | null {
647
+ let dir = startDir;
648
+ for (let i = 0; i <= NODE_MODULES_SEARCH_MAX_LEVELS; i++) {
649
+ const candidate = path.join(dir, 'node_modules', packageName);
650
+ if (this.fs.exists(candidate) && this.isDirectorySync(candidate)) return candidate;
651
+ const parent = path.dirname(dir);
652
+ if (parent === dir) break;
653
+ dir = parent;
654
+ }
655
+ return null;
656
+ }
657
+
658
+ private truncateForTool(text: string, maxChars: number = MAX_TOOL_RESULT_CHARS): string {
659
+ if (text.length <= maxChars) return text;
660
+ const omitted = text.length - maxChars;
661
+ return `${text.slice(0, maxChars)}\n… (truncated, ${omitted} characters omitted)`;
662
+ }
663
+
664
+ private async collectFiles(rootDir: string, ignoredDirs: Set<string>): Promise<string[]> {
665
+ const results: string[] = [];
666
+
667
+ const walk = async (dir: string): Promise<void> => {
668
+ const entries = await this.fs.readdir(dir, { withFileTypes: true });
669
+ for (const entry of entries) {
670
+ const fullPath = path.join(dir, entry.name);
671
+ if (entry.isDirectory()) {
672
+ if (ignoredDirs.has(entry.name)) continue;
673
+ await walk(fullPath);
674
+ } else if (entry.isFile()) {
675
+ results.push(fullPath);
676
+ }
677
+ }
678
+ };
679
+
680
+ await walk(rootDir);
681
+ return results;
682
+ }
683
+
684
+ private async findFilesByName(root: string, ignoredDirs: Set<string>, names: Set<string>): Promise<string[]> {
685
+ const results: string[] = [];
686
+
687
+ const walk = async (dir: string): Promise<void> => {
688
+ const entries = await this.fs.readdir(dir, { withFileTypes: true });
689
+ for (const entry of entries) {
690
+ const fullPath = path.join(dir, entry.name);
691
+ if (entry.isDirectory()) {
692
+ if (ignoredDirs.has(entry.name)) continue;
693
+ await walk(fullPath);
694
+ } else if (entry.isFile() && names.has(entry.name)) {
695
+ results.push(fullPath);
696
+ }
697
+ }
698
+ };
699
+
700
+ await walk(root);
701
+ return results;
702
+ }
703
+
704
+ private summarizeManifest(filename: string, data: any): string {
705
+ if (filename === 'package.json') {
706
+ const deps = Object.keys(data.dependencies || {});
707
+ const devDeps = Object.keys(data.devDependencies || {});
708
+ const lines = [`Name: ${data.name ?? '(unnamed)'}`, `Version: ${data.version ?? '?'}`];
709
+ if (data.workspaces) lines.push(`Workspaces: ${JSON.stringify(data.workspaces)}`);
710
+ if (deps.length) lines.push(`Dependencies: ${deps.join(', ')}`);
711
+ if (devDeps.length) lines.push(`Dev dependencies: ${devDeps.join(', ')}`);
712
+ return lines.join('\n');
713
+ }
714
+
715
+ if (filename === 'composer.json') {
716
+ const req = Object.keys(data.require || {});
717
+ const reqDev = Object.keys(data['require-dev'] || {});
718
+ const lines = [`Name: ${data.name ?? '(unnamed)'}`];
719
+ if (req.length) lines.push(`require: ${req.join(', ')}`);
720
+ if (reqDev.length) lines.push(`require-dev: ${reqDev.join(', ')}`);
721
+ if (data.autoload) lines.push(`Autoload: ${JSON.stringify(data.autoload)}`);
722
+ return lines.join('\n');
723
+ }
724
+
725
+ return JSON.stringify(data);
726
+ }
727
+
728
+ private summarizeDependencyManifest(data: any): string {
729
+ const lines = [`Name: ${data.name ?? '(unnamed)'}`, `Version: ${data.version ?? '?'}`];
730
+ if (data.description) lines.push(`Description: ${data.description}`);
731
+ if (data.main) lines.push(`main: ${data.main}`);
732
+ if (data.module) lines.push(`module: ${data.module}`);
733
+ if (data.types || data.typings) lines.push(`types: ${data.types ?? data.typings}`);
734
+ if (data.exports) lines.push(`exports: ${JSON.stringify(data.exports, null, 2)}`);
735
+ if (data.bin) lines.push(`bin: ${JSON.stringify(data.bin)}`);
736
+ return lines.join('\n');
737
+ }
738
+
739
+ private async toolListDirectory(ctx: AgentToolContext, input: any): Promise<string> {
740
+ const target = this.resolveSafePath(ctx.explorationRoot, input?.path);
741
+ if (!this.isDirectorySync(target)) return `Error: "${input?.path}" is not a directory within the explorable area.`;
742
+
743
+ const entries = await this.fs.readdir(target, { withFileTypes: true });
744
+ const visible = entries.filter(e => !(e.isDirectory() && ctx.ignoredDirs.has(e.name)));
745
+ if (visible.length === 0) return '(empty directory)';
746
+
747
+ return visible.map(e => `${e.name}${e.isDirectory() ? '/' : ''}`).join('\n');
748
+ }
749
+
750
+ private async toolReadFile(ctx: AgentToolContext, input: any): Promise<string> {
751
+ if (!input?.path) return 'Error: missing "path" parameter.';
752
+ const target = this.resolveSafePath(ctx.explorationRoot, input.path);
753
+
754
+ if (!this.fs.exists(target)) return `Error: file does not exist: ${input.path}`;
755
+ if (this.isDirectorySync(target)) return `Error: "${input.path}" is a directory, use list_directory.`;
756
+
757
+ const content = await this.fs.read(target);
758
+ if (!content || !content.trim()) {
759
+ ctx.seenFiles.add(target);
760
+ return '(empty file)';
761
+ }
762
+
763
+ if (content.length > MAX_TOOL_RESULT_CHARS) {
764
+ // Truncated: the model has NOT seen the full file, so it is not marked as "seen" —
765
+ // applyPatches() will refuse to blindly overwrite it later.
766
+ return this.truncateForTool(content);
767
+ }
768
+
769
+ ctx.seenFiles.add(target);
770
+ return content;
771
+ }
772
+
773
+ private async toolFindAgentsMd(ctx: AgentToolContext): Promise<string> {
774
+ const found = await this.findFilesByName(ctx.explorationRoot, ctx.ignoredDirs, new Set([AGENTS_MD_FILENAME]));
775
+ if (found.length === 0) return 'No existing AGENTS.md files found in the explorable area.';
776
+ return found.map(p => path.relative(ctx.explorationRoot, p)).join('\n');
777
+ }
778
+
779
+ private async toolReadManifest(ctx: AgentToolContext): Promise<string> {
780
+ const manifests = await this.findFilesByName(ctx.explorationRoot, ctx.ignoredDirs, MANIFEST_FILENAMES);
781
+ if (manifests.length === 0) return 'No package.json or composer.json found in the explorable area.';
782
+
783
+ const parts: string[] = [];
784
+ for (const manifestPath of manifests) {
785
+ const rel = path.relative(ctx.explorationRoot, manifestPath);
786
+ const raw = await this.fs.read(manifestPath);
787
+ try {
788
+ const data = JSON.parse(raw ?? '{}');
789
+ parts.push(`# ${rel}\n${this.summarizeManifest(path.basename(manifestPath), data)}`);
790
+ } catch {
791
+ parts.push(`# ${rel}\n(could not be parsed as valid JSON)`);
792
+ }
793
+ }
794
+
795
+ return this.truncateForTool(parts.join('\n\n'));
796
+ }
797
+
798
+ private async toolSearchDependencyUsage(ctx: AgentToolContext, input: any): Promise<string> {
799
+ const dependency: string | undefined = input?.dependency;
800
+ if (!dependency) return 'Error: missing "dependency" parameter.';
801
+
802
+ const files = await this.collectFiles(ctx.explorationRoot, ctx.ignoredDirs);
803
+ const matches: string[] = [];
804
+
805
+ for (const filePath of files) {
806
+ if (matches.length >= MAX_DEPENDENCY_MATCHES) break;
807
+ if (!READABLE_EXTENSIONS.has(path.extname(filePath))) continue;
808
+
809
+ const content = await this.fs.read(filePath);
810
+ if (!content) continue;
811
+
812
+ const rel = path.relative(ctx.explorationRoot, filePath);
813
+ const lines = content.split('\n');
814
+
815
+ for (let i = 0; i < lines.length && matches.length < MAX_DEPENDENCY_MATCHES; i++) {
816
+ const line = lines[i];
817
+ if (line.includes(dependency) && /\b(import|require|use|from)\b/.test(line)) {
818
+ matches.push(`${rel}:${i + 1}: ${line.trim()}`);
819
+ }
820
+ }
821
+ }
822
+
823
+ if (matches.length === 0) return `No import/require/use references to "${dependency}" found in the code.`;
824
+ return this.truncateForTool(matches.join('\n'));
825
+ }
826
+
827
+ private async toolReadDependencyManifest(ctx: AgentToolContext, input: any): Promise<string> {
828
+ const packageName = input?.packageName;
829
+ if (!packageName) return 'Error: missing "packageName" parameter.';
830
+
831
+ const depDir = this.resolveDependencyDir(ctx.projectDir, packageName);
832
+ if (!depDir) return `Error: "${packageName}" was not found in any node_modules reachable from the project.`;
833
+
834
+ const manifestPath = path.join(depDir, 'package.json');
835
+ if (!this.fs.exists(manifestPath)) return `Error: "${packageName}" has no package.json in ${depDir}.`;
836
+
837
+ const raw = await this.fs.read(manifestPath);
838
+ try {
839
+ return this.summarizeDependencyManifest(JSON.parse(raw ?? '{}'));
840
+ } catch {
841
+ return `Error: "${packageName}"'s package.json is not valid JSON.`;
842
+ }
843
+ }
844
+
845
+ private async toolListDependencyFiles(ctx: AgentToolContext, input: any): Promise<string> {
846
+ const packageName = input?.packageName;
847
+ if (!packageName) return 'Error: missing "packageName" parameter.';
848
+
849
+ const depDir = this.resolveDependencyDir(ctx.projectDir, packageName);
850
+ if (!depDir) return `Error: "${packageName}" was not found in any node_modules reachable from the project.`;
851
+
852
+ let target: string;
853
+ try {
854
+ target = this.resolveSafePath(depDir, input?.path);
855
+ } catch (err: any) {
856
+ return `Error: ${err.message}`;
857
+ }
858
+
859
+ if (!this.isDirectorySync(target)) return `Error: "${input?.path ?? '.'}" is not a directory within "${packageName}".`;
860
+
861
+ const entries = await this.fs.readdir(target, { withFileTypes: true });
862
+ const visible = entries.filter(e => !(e.isDirectory() && PACKAGE_IGNORED_DIRS.has(e.name)));
863
+ if (visible.length === 0) return '(empty directory)';
864
+
865
+ return visible.map(e => `${e.name}${e.isDirectory() ? '/' : ''}`).join('\n');
866
+ }
867
+
868
+ private async toolReadDependencyFile(ctx: AgentToolContext, input: any): Promise<string> {
869
+ const packageName = input?.packageName;
870
+ if (!packageName) return 'Error: missing "packageName" parameter.';
871
+ if (!input?.path) return 'Error: missing "path" parameter.';
872
+
873
+ const depDir = this.resolveDependencyDir(ctx.projectDir, packageName);
874
+ if (!depDir) return `Error: "${packageName}" was not found in any node_modules reachable from the project.`;
875
+
876
+ let target: string;
877
+ try {
878
+ target = this.resolveSafePath(depDir, input.path);
879
+ } catch (err: any) {
880
+ return `Error: ${err.message}`;
881
+ }
882
+
883
+ if (!this.fs.exists(target)) return `Error: file does not exist in "${packageName}": ${input.path}`;
884
+ if (this.isDirectorySync(target)) return `Error: "${input.path}" is a directory within "${packageName}", use list_dependency_files.`;
885
+
886
+ const content = await this.fs.read(target);
887
+ if (!content || !content.trim()) return '(empty file)';
888
+ return this.truncateForTool(content);
889
+ }
890
+
891
+ private async executeAgentTool(ctx: AgentToolContext, name: string, input: any): Promise<string> {
892
+ try {
893
+ switch (name) {
894
+ case 'list_directory': return await this.toolListDirectory(ctx, input);
895
+ case 'read_file': return await this.toolReadFile(ctx, input);
896
+ case 'find_agents_md': return await this.toolFindAgentsMd(ctx);
897
+ case 'read_manifest': return await this.toolReadManifest(ctx);
898
+ case 'search_dependency_usage': return await this.toolSearchDependencyUsage(ctx, input);
899
+ case 'read_dependency_manifest': return await this.toolReadDependencyManifest(ctx, input);
900
+ case 'list_dependency_files': return await this.toolListDependencyFiles(ctx, input);
901
+ case 'read_dependency_file': return await this.toolReadDependencyFile(ctx, input);
902
+ default: return `Error: unknown tool "${name}".`;
903
+ }
904
+ } catch (err: any) {
905
+ return `Error running tool "${name}": ${err?.message ?? String(err)}`;
906
+ }
907
+ }
908
+
909
+ // === Agent loop (tool use + Loop Detector + priority routing) ================================
910
+
911
+ /**
912
+ * Runs the conversation with the model, letting it call AGENT_TOOLS for extra context before
913
+ * delivering a final answer. Mutates `messages` in place (pushes assistant/tool turns onto it)
914
+ * so a caller doing self-healing retries can keep extending the same conversation. Stops when
915
+ * the model answers without requesting a tool, when maxIterations is reached (a final answer is
916
+ * then forced), or when the Loop Detector aborts a runaway sequence of identical tool calls.
917
+ */
918
+ private async runAgentLoop(
919
+ messages: AIMessage[],
920
+ ctx: AgentToolContext,
921
+ tokens: TokenManager,
922
+ options: AgentRunOptions = {}
923
+ ): Promise<AgentRunResult> {
924
+ const priority = options.priority ?? 'media-prioridad';
925
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_AGENT_ITERATIONS;
926
+ const completeOptions = { tools: AGENT_TOOLS, ...(options.maxTokens ? { maxTokens: options.maxTokens } : {}) };
927
+
928
+ let totalPromptTokens = 0;
929
+ let totalCompletionTokens = 0;
930
+ let toolCallsUsed = 0;
931
+ let lastCallSignature: string | null = null;
932
+ let consecutiveCount = 0;
933
+
934
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
935
+ tokens.assertWithinLimit(messages);
936
+ const result = await this.ai.completeWithPriority(messages, priority, completeOptions);
937
+
938
+ totalPromptTokens += result.promptTokens ?? 0;
939
+ totalCompletionTokens += result.completionTokens ?? 0;
940
+ tokens.recordUsage(result.vendor, result.model, result.promptTokens ?? 0, result.completionTokens ?? 0);
941
+
942
+ const toolUses = (result.blocks ?? []).filter(
943
+ (b): b is Extract<AIContentBlock, { type: 'tool_use' }> => b.type === 'tool_use'
944
+ );
945
+
946
+ if (toolUses.length === 0) {
947
+ return { content: result.content, promptTokens: totalPromptTokens, completionTokens: totalCompletionTokens, toolCallsUsed, priorityUsed: priority };
948
+ }
949
+
950
+ messages.push({ role: 'assistant', content: result.blocks });
951
+
952
+ const toolResults: AIContentBlock[] = [];
953
+ let aborted = false;
954
+
955
+ for (const call of toolUses) {
956
+ toolCallsUsed++;
957
+ const signature = `${call.name}(${JSON.stringify(call.input ?? {})})`;
958
+ consecutiveCount = signature === lastCallSignature ? consecutiveCount + 1 : 1;
959
+ lastCallSignature = signature;
960
+
961
+ if (consecutiveCount >= LOOP_DETECTOR_REPEAT_THRESHOLD) {
962
+ this.logger.warn(`Loop detector: '${call.name}' called with identical arguments ${consecutiveCount} times in a row — aborting the agent loop.`);
963
+ toolResults.push({
964
+ type: 'tool_result',
965
+ tool_use_id: call.id,
966
+ content: 'Error: aborted by the loop detector (identical tool call repeated too many times).',
967
+ is_error: true,
968
+ });
969
+ aborted = true;
970
+ break;
971
+ }
972
+
973
+ this.logger.info(`Agent tool call → ${signature}`);
974
+ const output = await this.executeAgentTool(ctx, call.name, call.input);
975
+ toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: output });
976
+ }
977
+
978
+ messages.push({ role: 'user', content: toolResults });
979
+
980
+ if (aborted) {
981
+ return {
982
+ content: '(stopped early: the loop detector aborted the session after a repeated tool call)',
983
+ promptTokens: totalPromptTokens,
984
+ completionTokens: totalCompletionTokens,
985
+ toolCallsUsed,
986
+ priorityUsed: priority,
987
+ };
988
+ }
989
+ }
990
+
991
+ this.logger.info(`Agent iteration limit reached (${maxIterations}); forcing a final answer.`);
992
+ messages.push({ role: 'user', content: 'No more tool calls are available. Deliver your final answer now with whatever information you have.' });
993
+
994
+ tokens.assertWithinLimit(messages);
995
+ const final = await this.ai.completeWithPriority(messages, priority, options.maxTokens ? { maxTokens: options.maxTokens } : {});
996
+ totalPromptTokens += final.promptTokens ?? 0;
997
+ totalCompletionTokens += final.completionTokens ?? 0;
998
+ tokens.recordUsage(final.vendor, final.model, final.promptTokens ?? 0, final.completionTokens ?? 0);
999
+
1000
+ return { content: final.content, promptTokens: totalPromptTokens, completionTokens: totalCompletionTokens, toolCallsUsed, priorityUsed: priority };
1001
+ }
1002
+
1003
+ // === Search/Replace patch engine ===============================================================
1004
+
1005
+ private parsePatchBlocks(responseText: string): FilePatchBlock[] {
1006
+ const blocks: FilePatchBlock[] = [];
1007
+
1008
+ FILE_BLOCK_REGEX.lastIndex = 0;
1009
+ let fileMatch: RegExpExecArray | null;
1010
+ while ((fileMatch = FILE_BLOCK_REGEX.exec(responseText)) !== null) {
1011
+ const relPath = fileMatch[1].trim();
1012
+ const body = fileMatch[2];
1013
+ const edits: FilePatchEdit[] = [];
1014
+
1015
+ SEARCH_REPLACE_REGEX.lastIndex = 0;
1016
+ let editMatch: RegExpExecArray | null;
1017
+ while ((editMatch = SEARCH_REPLACE_REGEX.exec(body)) !== null) {
1018
+ edits.push({ search: editMatch[1], replace: editMatch[2] });
1019
+ }
1020
+
1021
+ if (edits.length > 0) blocks.push({ relPath, edits });
1022
+ }
1023
+
1024
+ return blocks;
1025
+ }
1026
+
1027
+ /**
1028
+ * Applies one SEARCH/REPLACE edit to `original`. Tries an exact substring match first; if that
1029
+ * fails, falls back to a line-by-line match that ignores leading/trailing whitespace per line,
1030
+ * so the model doesn't have to reproduce indentation byte-for-byte. Returns applied: false if
1031
+ * neither matches, so the caller can reject the edit instead of corrupting the file.
1032
+ */
1033
+ private applySearchReplace(original: string, search: string, replace: string): { content: string; applied: boolean } {
1034
+ if (search.trim() === '') {
1035
+ // Only meaningful for brand-new files; callers must guard this case before writing to
1036
+ // an existing file with an empty SEARCH block.
1037
+ return { content: replace, applied: true };
1038
+ }
1039
+
1040
+ const exactIndex = original.indexOf(search);
1041
+ if (exactIndex !== -1) {
1042
+ return { content: original.slice(0, exactIndex) + replace + original.slice(exactIndex + search.length), applied: true };
1043
+ }
1044
+
1045
+ const originalLines = original.split('\n');
1046
+ const searchLines = search.split('\n');
1047
+ const normalize = (line: string) => line.trim();
1048
+
1049
+ for (let start = 0; start <= originalLines.length - searchLines.length; start++) {
1050
+ let matches = true;
1051
+ for (let i = 0; i < searchLines.length; i++) {
1052
+ if (normalize(originalLines[start + i]) !== normalize(searchLines[i])) { matches = false; break; }
1053
+ }
1054
+ if (!matches) continue;
1055
+
1056
+ const before = originalLines.slice(0, start);
1057
+ const after = originalLines.slice(start + searchLines.length);
1058
+ return { content: [...before, replace, ...after].join('\n'), applied: true };
1059
+ }
1060
+
1061
+ return { content: original, applied: false };
1062
+ }
1063
+
1064
+ /**
1065
+ * Applies every parsed patch block within `targetDir`. Enforces two safety rules regardless of
1066
+ * what the model asked for: writes may never resolve outside targetDir, and an existing file is
1067
+ * never overwritten unless its full content was actually seen this session (seenFiles) — a
1068
+ * brand-new file (empty SEARCH, no existing file at that path) is exempt from the second rule.
1069
+ */
1070
+ private async applyPatches(
1071
+ targetDir: string,
1072
+ blocks: FilePatchBlock[],
1073
+ seenFiles: Set<string>
1074
+ ): Promise<{ filesChanged: string[]; blockedWrites: string[]; failedEdits: string[] }> {
1075
+ const filesChanged: string[] = [];
1076
+ const blockedWrites: string[] = [];
1077
+ const failedEdits: string[] = [];
1078
+
1079
+ for (const block of blocks) {
1080
+ const resolvedPath = path.resolve(targetDir, block.relPath);
1081
+ const withinTarget = resolvedPath === targetDir || resolvedPath.startsWith(targetDir + path.sep);
1082
+
1083
+ if (!withinTarget) {
1084
+ this.logger.warn(`Patch ignored, outside the working directory: ${block.relPath}`);
1085
+ continue;
1086
+ }
1087
+
1088
+ const existed = this.fs.exists(resolvedPath);
1089
+ const isNewFileEdit = block.edits.length === 1 && block.edits[0].search.trim() === '';
1090
+
1091
+ if (existed && !seenFiles.has(resolvedPath)) {
1092
+ this.logger.warn(`Patch REJECTED (no blind overwrite): "${block.relPath}" already exists and its full content was not read this session.`);
1093
+ blockedWrites.push(block.relPath);
1094
+ continue;
1095
+ }
1096
+
1097
+ if (!existed && !isNewFileEdit) {
1098
+ this.logger.warn(`Patch ignored: "${block.relPath}" does not exist and the block is not a valid new-file creation (empty SEARCH).`);
1099
+ failedEdits.push(block.relPath);
1100
+ continue;
1101
+ }
1102
+
1103
+ let content = existed ? (await this.fs.read(resolvedPath)) ?? '' : '';
1104
+ let ok = true;
1105
+
1106
+ for (const edit of block.edits) {
1107
+ const result = this.applySearchReplace(content, edit.search, edit.replace);
1108
+ if (!result.applied) { ok = false; break; }
1109
+ content = result.content;
1110
+ }
1111
+
1112
+ if (!ok) {
1113
+ this.logger.warn(`Patch failed to apply (SEARCH text not found, even fuzzily): ${block.relPath}`);
1114
+ failedEdits.push(block.relPath);
1115
+ continue;
1116
+ }
1117
+
1118
+ await this.fs.write(resolvedPath, content.endsWith('\n') ? content : content + '\n');
1119
+ seenFiles.add(resolvedPath);
1120
+ this.logger.success(`${existed ? 'Modified' : 'Created'}: ${resolvedPath}`);
1121
+ filesChanged.push(block.relPath);
1122
+ }
1123
+
1124
+ return { filesChanged, blockedWrites, failedEdits };
1125
+ }
1126
+
1127
+ // === Post-write validation (self-healing) ======================================================
1128
+
1129
+ /**
1130
+ * Best-effort, non-destructive validation: prefers `tsc --noEmit` when a tsconfig.json is
1131
+ * present, otherwise runs the project's `typecheck` or `lint` npm script if one exists. Returns
1132
+ * { ran: false } when no validator could be detected, which callers should treat as "assume ok"
1133
+ * rather than as a failure.
1134
+ */
1135
+ private async runValidation(dir: string): Promise<ValidationResult> {
1136
+ const originalCwd = this.shell.getCwd();
1137
+
1138
+ try {
1139
+ if (this.fs.exists(path.join(dir, 'tsconfig.json'))) {
1140
+ this.shell.cd(dir);
1141
+ await this.shell.exec('npx tsc --noEmit');
1142
+ return { ran: true, ok: true };
1143
+ }
1144
+
1145
+ const pkgPath = path.join(dir, 'package.json');
1146
+ if (this.fs.exists(pkgPath)) {
1147
+ const raw = await this.fs.read(pkgPath);
1148
+ let scripts: Record<string, string> = {};
1149
+ try {
1150
+ scripts = raw ? (JSON.parse(raw).scripts ?? {}) : {};
1151
+ } catch {
1152
+ scripts = {};
1153
+ }
1154
+
1155
+ const script = scripts.typecheck ? 'typecheck' : scripts.lint ? 'lint' : null;
1156
+ if (script) {
1157
+ this.shell.cd(dir);
1158
+ await this.shell.exec(`npm run ${script}`);
1159
+ return { ran: true, ok: true };
1160
+ }
1161
+ }
1162
+
1163
+ return { ran: false, ok: true };
1164
+ } catch (err: any) {
1165
+ const raw = err?.originalError?.stderr || err?.originalError?.stdout || err?.originalError?.message || err?.message;
1166
+ return { ran: true, ok: false, output: this.truncateForTool(String(raw ?? 'Unknown validation error')) };
1167
+ } finally {
1168
+ this.shell.cd(originalCwd);
1169
+ }
1170
+ }
1171
+
1172
+ // === High-level entry points ===================================================================
1173
+
1174
+ /**
1175
+ * @method runCodeAgent
1176
+ * @description Runs the full ai:code pipeline: the tool-use agent loop (seeded only with
1177
+ * `messages`, typically tree + AGENTS.md + the task, built by the caller via
1178
+ * PromptTemplateManager + buildExplorationTree()/getContext()), parses the model's
1179
+ * Search/Replace blocks, applies them under the blind-overwrite guard, and — if any file
1180
+ * changed — runs post-write validation. On a validation failure, the error is fed back into
1181
+ * the same conversation as a message and the routing priority is bumped one level (see
1182
+ * AIVendorManager.bumpPriority), up to a small retry budget.
1183
+ * @param {string} targetDir - Directory the agent is allowed to write to.
1184
+ * @param {AIMessage[]} messages - Seed conversation (system + user), mutated in place.
1185
+ * @param {TokenManager} tokens - Injected by the caller so usage is tracked centrally.
1186
+ * @param {AgentRunOptions} options - priority (default 'media-prioridad'), maxIterations, maxTokens.
1187
+ * @returns {Promise<CodeAgentResult>}
1188
+ * @example
1189
+ * const messages = await prompts.build('ai-code', { task: prompt, tree }, targetDir);
1190
+ * const result = await aiContext.runCodeAgent(targetDir, messages, tokens, { priority: 'media-prioridad' });
1191
+ */
1192
+ public async runCodeAgent(
1193
+ targetDir: string,
1194
+ messages: AIMessage[],
1195
+ tokens: TokenManager,
1196
+ options: AgentRunOptions = {}
1197
+ ): Promise<CodeAgentResult> {
1198
+ let priority = options.priority ?? 'media-prioridad';
1199
+ const explorationRoot = this.computeExplorationRoot(targetDir);
1200
+ const ctx: AgentToolContext = { projectDir: targetDir, explorationRoot, ignoredDirs: DEFAULT_IGNORED_DIRS, seenFiles: new Set<string>() };
1201
+
1202
+ let promptTokens = 0;
1203
+ let completionTokens = 0;
1204
+ let toolCallsUsed = 0;
1205
+ let lastContent = '';
1206
+ let filesChanged: string[] = [];
1207
+ let blockedWrites: string[] = [];
1208
+ let failedEdits: string[] = [];
1209
+ let validation: ValidationResult = { ran: false, ok: true };
1210
+
1211
+ for (let attempt = 0; attempt <= MAX_SELF_HEAL_ATTEMPTS; attempt++) {
1212
+ const run = await this.runAgentLoop(messages, ctx, tokens, { ...options, priority });
1213
+ promptTokens += run.promptTokens;
1214
+ completionTokens += run.completionTokens;
1215
+ toolCallsUsed += run.toolCallsUsed;
1216
+ lastContent = run.content;
1217
+
1218
+ const blocks = this.parsePatchBlocks(run.content);
1219
+ if (blocks.length === 0) break;
1220
+
1221
+ const applied = await this.applyPatches(targetDir, blocks, ctx.seenFiles);
1222
+ filesChanged = applied.filesChanged;
1223
+ blockedWrites = applied.blockedWrites;
1224
+ failedEdits = applied.failedEdits;
1225
+ if (filesChanged.length === 0) break;
1226
+
1227
+ validation = await this.runValidation(targetDir);
1228
+ if (validation.ok || attempt === MAX_SELF_HEAL_ATTEMPTS) break;
1229
+
1230
+ const nextPriority = this.ai.bumpPriority(priority);
1231
+ this.logger.warn(
1232
+ `Post-write validation failed (attempt ${attempt + 1}/${MAX_SELF_HEAL_ATTEMPTS + 1}); asking the model to fix it and bumping priority: ${priority} → ${nextPriority}`
1233
+ );
1234
+ priority = nextPriority;
1235
+
1236
+ messages.push({ role: 'assistant', content: run.content });
1237
+ messages.push({
1238
+ role: 'user',
1239
+ content:
1240
+ `SYSTEM: Your change broke compilation/lint with the following error:\n\n${validation.output ?? '(no output captured)'}\n\n` +
1241
+ 'Fix it using more SEARCH/REPLACE blocks for the affected file(s). If you are not certain of a file\'s current ' +
1242
+ 'exact content after your previous edit, re-read it with read_file before writing a new SEARCH block against it.',
1243
+ });
1244
+ }
1245
+
1246
+ return { content: lastContent, promptTokens, completionTokens, toolCallsUsed, priorityUsed: priority, filesChanged, blockedWrites, failedEdits, validation };
1247
+ }
1248
+
1249
+ /**
1250
+ * @method runDescribeAgent
1251
+ * @description Runs the tool-use agent loop for ai:describe: no file writing, just exploration
1252
+ * (list_directory / read_file / read_manifest / etc.) and a final Markdown answer, which the
1253
+ * caller is responsible for writing to disk (the output filename is a command-layer policy,
1254
+ * not something this manager should decide).
1255
+ * @param {string} dir - Directory the agent is allowed to explore (read-only).
1256
+ * @param {AIMessage[]} messages - Seed conversation (system + user).
1257
+ * @param {TokenManager} tokens - Injected by the caller so usage is tracked centrally.
1258
+ * @param {AgentRunOptions} options - priority (default 'baja-media-prioridad'), maxIterations, maxTokens.
1259
+ * @returns {Promise<AgentRunResult>}
1260
+ * @example
1261
+ * const messages = await prompts.build('ai-describe-project', { tree, readme }, projectDir);
1262
+ * const result = await aiContext.runDescribeAgent(projectDir, messages, tokens);
1263
+ * await fs.write(path.join(projectDir, 'AGENTS.md'), result.content.trim() + '\n');
1264
+ */
1265
+ public async runDescribeAgent(
1266
+ dir: string,
1267
+ messages: AIMessage[],
1268
+ tokens: TokenManager,
1269
+ options: AgentRunOptions = {}
1270
+ ): Promise<AgentRunResult> {
1271
+ const explorationRoot = this.computeExplorationRoot(dir);
1272
+ const ctx: AgentToolContext = { projectDir: dir, explorationRoot, ignoredDirs: DEFAULT_IGNORED_DIRS, seenFiles: new Set<string>() };
1273
+ return this.runAgentLoop(messages, ctx, tokens, { ...options, priority: options.priority ?? 'baja-media-prioridad' });
1274
+ }
298
1275
  }
299
1276
 
300
1277
  export const AIContextManagerTests = {
301
1278
  findContextFiles: { dir: '~/Projects/TyrFramework' },
302
- };
1279
+ };