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