@orxataguy/tyr 1.0.30 → 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.
- package/README.md +49 -10
- package/package.json +1 -1
- package/src/core/Container.ts +4 -1
- package/src/core/Kernel.ts +2 -0
- package/src/core/sys/chat.ts +73 -0
- package/src/core/sys/help.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/AIContextManager.ts +1004 -27
- package/src/lib/AIVendorManager.ts +474 -25
- package/src/lib/ChatManager.ts +880 -0
- package/src/lib/PromptTemplateManager.ts +112 -1
- package/src/lib/TokenManager.ts +30 -2
|
@@ -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 {
|
|
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 =
|
|
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
|
|
50
|
-
* AGENTS.md
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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,
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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
|
+
};
|