chocolatito-code 1.0.0 → 1.2.0
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 +155 -0
- package/dist/agent/compaction.d.ts +65 -0
- package/dist/agent/compaction.js +134 -0
- package/dist/agent/context.d.ts +3 -0
- package/dist/agent/context.js +35 -0
- package/dist/agent/loop.d.ts +91 -2
- package/dist/agent/loop.js +642 -91
- package/dist/agent/loopDetector.d.ts +57 -0
- package/dist/agent/loopDetector.js +0 -0
- package/dist/agent/mentions.d.ts +13 -0
- package/dist/agent/mentions.js +49 -0
- package/dist/agent/repoMap.d.ts +117 -0
- package/dist/agent/repoMap.js +560 -0
- package/dist/agent/result.d.ts +64 -0
- package/dist/agent/result.js +48 -0
- package/dist/agent/retry.d.ts +35 -0
- package/dist/agent/retry.js +123 -4
- package/dist/agent/salidaDelAgente.d.ts +43 -0
- package/dist/agent/salidaDelAgente.js +53 -0
- package/dist/agent/subagent.js +62 -15
- package/dist/agent/syntaxValidator.js +288 -52
- package/dist/agent/toolGate.d.ts +58 -0
- package/dist/agent/toolGate.js +111 -0
- package/dist/agent/tracker.js +4 -7
- package/dist/agent/undoManager.d.ts +44 -11
- package/dist/agent/undoManager.js +95 -24
- package/dist/agent/usageMeter.d.ts +56 -0
- package/dist/agent/usageMeter.js +51 -0
- package/dist/agent/verifier.d.ts +49 -0
- package/dist/agent/verifier.js +158 -0
- package/dist/config/constants.js +14 -0
- package/dist/config/engine.d.ts +19 -0
- package/dist/config/engine.js +35 -3
- package/dist/config/license.d.ts +5 -0
- package/dist/config/license.js +107 -9
- package/dist/config/limits.d.ts +28 -0
- package/dist/config/limits.js +51 -0
- package/dist/config/nodeVersion.d.ts +13 -0
- package/dist/config/nodeVersion.js +26 -0
- package/dist/config/permissions.d.ts +76 -1
- package/dist/config/permissions.js +379 -23
- package/dist/config/quota.js +24 -1
- package/dist/config/updater.d.ts +68 -0
- package/dist/config/updater.js +215 -0
- package/dist/hooks/manager.js +159 -14
- package/dist/index.js +240 -37
- package/dist/mcp/client.d.ts +50 -0
- package/dist/mcp/client.js +155 -19
- package/dist/mcp/ide.d.ts +76 -0
- package/dist/mcp/ide.js +160 -0
- package/dist/mcp/manager.js +9 -0
- package/dist/prompts/systemPrompt.js +12 -1
- package/dist/sessions/manager.d.ts +12 -0
- package/dist/sessions/manager.js +33 -5
- package/dist/sessions/resume.d.ts +36 -0
- package/dist/sessions/resume.js +43 -0
- package/dist/skills/manager.d.ts +1 -0
- package/dist/skills/manager.js +9 -0
- package/dist/tools/binary.d.ts +46 -0
- package/dist/tools/binary.js +100 -0
- package/dist/tools/browserCdp.js +13 -1
- package/dist/tools/browserExtension.d.ts +5 -0
- package/dist/tools/browserExtension.js +237 -22
- package/dist/tools/browserSession.d.ts +23 -0
- package/dist/tools/browserSession.js +28 -0
- package/dist/tools/computerUse.js +33 -1
- package/dist/tools/createImage.d.ts +11 -0
- package/dist/tools/createImage.js +13 -1
- package/dist/tools/definitions.js +17 -4
- package/dist/tools/editDiagnosis.d.ts +54 -0
- package/dist/tools/editDiagnosis.js +142 -0
- package/dist/tools/editFile.d.ts +1 -0
- package/dist/tools/editFile.js +118 -62
- package/dist/tools/findFiles.d.ts +21 -0
- package/dist/tools/findFiles.js +75 -6
- package/dist/tools/getSystemInfo.js +15 -7
- package/dist/tools/gitTools.d.ts +24 -0
- package/dist/tools/gitTools.js +87 -26
- package/dist/tools/grepSearch.js +111 -23
- package/dist/tools/moveCopyFile.d.ts +18 -1
- package/dist/tools/moveCopyFile.js +74 -4
- package/dist/tools/patchCascade.d.ts +57 -0
- package/dist/tools/patchCascade.js +338 -0
- package/dist/tools/runCommand.js +213 -25
- package/dist/tools/runner.js +58 -5
- package/dist/tools/toolDefsComputer.js +12 -2
- package/dist/tools/truncator.d.ts +27 -1
- package/dist/tools/truncator.js +117 -11
- package/dist/tools/viewFile.js +15 -11
- package/dist/tools/visionBridge.js +7 -0
- package/dist/tools/webSearch.d.ts +21 -0
- package/dist/tools/webSearch.js +125 -14
- package/dist/tools/writeFile.d.ts +16 -1
- package/dist/tools/writeFile.js +40 -2
- package/dist/ui/interrupt.d.ts +4 -1
- package/dist/ui/interrupt.js +59 -7
- package/dist/ui/keyboardGuard.d.ts +76 -0
- package/dist/ui/keyboardGuard.js +94 -0
- package/dist/ui/loopPrompt.d.ts +17 -0
- package/dist/ui/loopPrompt.js +59 -0
- package/dist/ui/pegados.d.ts +50 -0
- package/dist/ui/pegados.js +83 -0
- package/dist/ui/permissionPrompt.js +84 -2
- package/dist/ui/prompt.d.ts +40 -0
- package/dist/ui/prompt.js +436 -131
- package/dist/ui/reasoningStream.d.ts +8 -0
- package/dist/ui/reasoningStream.js +99 -7
- package/dist/ui/salida.d.ts +10 -0
- package/dist/ui/salida.js +50 -0
- package/extension/README.md +95 -74
- package/extension/background.js +772 -181
- package/extension/content.js +527 -46
- package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
- package/extension/iconos/128.png +0 -0
- package/extension/iconos/16.png +0 -0
- package/extension/iconos/32.png +0 -0
- package/extension/iconos/48.png +0 -0
- package/extension/iconos/logotipo.png +0 -0
- package/extension/manifest.json +28 -4
- package/extension/popup.html +16 -6
- package/extension/popup.js +28 -12
- package/package.json +3 -2
package/dist/agent/loop.js
CHANGED
|
@@ -5,20 +5,124 @@ import { executeToolCall } from "../tools/runner.js";
|
|
|
5
5
|
import { TokenTracker } from "./tracker.js";
|
|
6
6
|
import { renderToolStart, renderToolSuccess, renderToolError, renderDiff, renderFooter, renderError, } from "../ui/renderer.js";
|
|
7
7
|
import { CONTEXT_WINDOW, MAX_OUTPUT_TOKENS, COMPACT_THRESHOLD_TOKENS, } from "../config/constants.js";
|
|
8
|
-
import { withRetry } from "./retry.js";
|
|
8
|
+
import { withRetry, isContextOverflow } from "./retry.js";
|
|
9
9
|
import { canRunInParallel } from "../tools/safety.js";
|
|
10
|
-
import { createEngineClient, explicarErrorDelMotor } from "../config/engine.js";
|
|
10
|
+
import { createEngineClient, explicarErrorDelMotor, motivoDeParadaDelError, } from "../config/engine.js";
|
|
11
11
|
import { MarkdownStream } from "../ui/markdownStream.js";
|
|
12
12
|
import { checkQuota, recordUsage, mensajeDeCorte, quotaBadge } from "../config/quota.js";
|
|
13
13
|
import { ReasoningStream } from "../ui/reasoningStream.js";
|
|
14
|
-
import { PermissionManager } from "../config/permissions.js";
|
|
14
|
+
import { PermissionManager, commandSignature, isDangerousCommand } from "../config/permissions.js";
|
|
15
15
|
import { askToolPermission } from "../ui/permissionPrompt.js";
|
|
16
16
|
import { DynamicSpinner } from "../ui/spinner.js";
|
|
17
17
|
import { globalUndoManager } from "./undoManager.js";
|
|
18
|
+
import { salida } from "./salidaDelAgente.js";
|
|
18
19
|
import { InterruptWatcher, isAbortError } from "../ui/interrupt.js";
|
|
19
20
|
import { HookManager } from "../hooks/manager.js";
|
|
21
|
+
import { registerToolGate, autorizarHerramienta, notificarPostHerramienta } from "./toolGate.js";
|
|
22
|
+
import { DetectorDeBucle, mensajeDeBucle } from "./loopDetector.js";
|
|
23
|
+
import { preguntarPorBucle } from "../ui/loopPrompt.js";
|
|
24
|
+
import { MAX_REFLEXIONES, detectarComando, mensajeDeRendicion, mensajeDeVerificacion, verificacionAutomaticaActiva, verificar, } from "./verifier.js";
|
|
25
|
+
import { apuntarUso, tomarGastoAuxiliar } from "./usageMeter.js";
|
|
26
|
+
import { leerLimites } from "../config/limits.js";
|
|
27
|
+
import { corteSeguro, estimarTokens, limpiarHuerfanos, vaciarResultadosAntiguos, } from "./compaction.js";
|
|
28
|
+
/** Herramientas cuyo exito deja el proyecto en un estado que vale la pena verificar. */
|
|
29
|
+
const HERRAMIENTAS_QUE_ESCRIBEN = new Set([
|
|
30
|
+
"edit_file",
|
|
31
|
+
"write_file",
|
|
32
|
+
"move_copy_file",
|
|
33
|
+
"delete_file",
|
|
34
|
+
]);
|
|
35
|
+
/** Cortes por longitud seguidos que se toleran antes de rendirse. */
|
|
36
|
+
const MAX_CORTES_POR_LONGITUD = 2;
|
|
37
|
+
/**
|
|
38
|
+
* Respuestas vacias seguidas que se toleran antes de rendirse.
|
|
39
|
+
*
|
|
40
|
+
* Dos, no una: una respuesta vacia suelta es casi siempre un hipo del motor y
|
|
41
|
+
* el reintento la resuelve sin que el usuario se entere. Tres seguidas ya no es
|
|
42
|
+
* un hipo, y seguir reintentando en silencio es lo que hacia que pareciera que
|
|
43
|
+
* el agente te ignora.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_RESPUESTAS_VACIAS = 1;
|
|
46
|
+
/** Mensajes recientes que la compactacion no resume nunca. */
|
|
47
|
+
const COLA_INTACTA = 4;
|
|
48
|
+
/**
|
|
49
|
+
* Mensajes recientes cuyas salidas de herramienta se conservan enteras. Las
|
|
50
|
+
* anteriores se vacian antes de plantearse siquiera resumir.
|
|
51
|
+
*/
|
|
52
|
+
const RESULTADOS_INTACTOS = 12;
|
|
53
|
+
/**
|
|
54
|
+
* Argumentos de una llamada a herramienta, distinguiendo el caso que importa.
|
|
55
|
+
*
|
|
56
|
+
* Un `JSON.parse` que falla no significa "esta herramienta no lleva
|
|
57
|
+
* argumentos": significa que el modelo se quedo sin presupuesto de salida a
|
|
58
|
+
* mitad de escribirlos. Tratar las dos cosas igual —que es lo que se hacia,
|
|
59
|
+
* con un `catch { fnArgs = {} }`— ejecuta la herramienta con las manos vacias:
|
|
60
|
+
* un write_file sin ruta ni contenido, un run_command sin comando. Y responde
|
|
61
|
+
* como si nada.
|
|
62
|
+
*/
|
|
63
|
+
function parsearArgumentos(bruto) {
|
|
64
|
+
const texto = (bruto ?? "").trim();
|
|
65
|
+
// Sin argumentos de verdad: hay herramientas que no llevan ninguno.
|
|
66
|
+
if (!texto || texto === "{}")
|
|
67
|
+
return { ok: true, args: {} };
|
|
68
|
+
try {
|
|
69
|
+
const args = JSON.parse(texto);
|
|
70
|
+
if (args && typeof args === "object" && !Array.isArray(args))
|
|
71
|
+
return { ok: true, args };
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
return { ok: false, args: {} };
|
|
75
|
+
}
|
|
76
|
+
/** Lo que se le dice al modelo cuando sus argumentos llegaron a medias. */
|
|
77
|
+
function mensajeDeArgumentosRotos(herramienta) {
|
|
78
|
+
return (`No se ejecuto "${herramienta}": los argumentos llegaron incompletos, seguramente porque la ` +
|
|
79
|
+
`respuesta se corto por longitud. No se ha hecho nada.
|
|
80
|
+
|
|
81
|
+
` +
|
|
82
|
+
`Si era un archivo largo, hazlo por partes: una primera llamada a write_file con el principio ` +
|
|
83
|
+
`y las siguientes con modo="anadir" para ir pegando el resto. Cada trozo, por debajo de unas ` +
|
|
84
|
+
`400 lineas.`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Deja una llamada a herramienta en un estado que la API acepte.
|
|
88
|
+
*
|
|
89
|
+
* EL FALLO QUE CIERRA ESTO
|
|
90
|
+
*
|
|
91
|
+
* Cuando la respuesta se corta a mitad de un `write_file` con un HTML largo
|
|
92
|
+
* dentro, los argumentos llegan asi:
|
|
93
|
+
*
|
|
94
|
+
* {"path":"index.html","content":"<!doctype html><html><head><style>body{ma
|
|
95
|
+
*
|
|
96
|
+
* La herramienta ya no se ejecutaba -eso se arreglo antes-, pero la llamada se
|
|
97
|
+
* guardaba en el historial TAL CUAL, y el historial se reenvia entero en cada
|
|
98
|
+
* peticion. La API valida que los argumentos sean JSON, no lo son, y contesta
|
|
99
|
+
* 400. Desde ese momento la sesion queda inservible: todo lo que escriba el
|
|
100
|
+
* usuario falla igual, la unica salida es /clear, y nadie lo sabe.
|
|
101
|
+
*
|
|
102
|
+
* Se vio en una sesion real, y encima el 400 se llevo por delante los cuatro
|
|
103
|
+
* huecos de peticion de la licencia, asi que al fallo se le sumo un "Tienes 4
|
|
104
|
+
* peticiones en curso" de dos minutos y medio.
|
|
105
|
+
*
|
|
106
|
+
* Guardando "{}" el historial sigue siendo valido y la pareja llamada/respuesta
|
|
107
|
+
* cuadra. Al modelo no se le oculta nada: el resultado de esa herramienta ya le
|
|
108
|
+
* dice que sus argumentos llegaron a medias y que no se hizo nada.
|
|
109
|
+
*/
|
|
110
|
+
function sanearLlamada(tc) {
|
|
111
|
+
const bruto = tc?.function?.arguments;
|
|
112
|
+
const texto = typeof bruto === "string" ? bruto.trim() : "";
|
|
113
|
+
if (texto && parsearArgumentos(texto).ok)
|
|
114
|
+
return tc;
|
|
115
|
+
return { ...tc, function: { ...tc.function, arguments: "{}" } };
|
|
116
|
+
}
|
|
20
117
|
export class AgentLoop {
|
|
21
118
|
client;
|
|
119
|
+
/**
|
|
120
|
+
* Cliente aparte para resumir el historial. No es cosmetico: la cabecera
|
|
121
|
+
* X-Chocolatito-Route es lo que le permite al proxy saber en que se le va la
|
|
122
|
+
* cuota al usuario, y con un unico cliente las compactaciones —que son las
|
|
123
|
+
* llamadas mas caras que hace el agente— llegaban etiquetadas como "loop".
|
|
124
|
+
*/
|
|
125
|
+
compactClient;
|
|
22
126
|
currentEspectro;
|
|
23
127
|
messages = [];
|
|
24
128
|
tracker;
|
|
@@ -27,17 +131,36 @@ export class AgentLoop {
|
|
|
27
131
|
hooks;
|
|
28
132
|
apiKey;
|
|
29
133
|
baseURL;
|
|
30
|
-
|
|
134
|
+
// El presupuesto de vueltas ya no esta escrito en el codigo: se lee de la
|
|
135
|
+
// configuracion del usuario, que es quien conoce su tarea y su cuota.
|
|
136
|
+
maxIterations = leerLimites().iteracionesPorTurno;
|
|
137
|
+
/**
|
|
138
|
+
* Vigila que el agente no se quede dando vueltas. Vive en la instancia y no
|
|
139
|
+
* en runTask porque el estado tiene que sobrevivir a las iteraciones del
|
|
140
|
+
* while; se reinicia en cada turno nuevo.
|
|
141
|
+
*/
|
|
142
|
+
detectorBucle = new DetectorDeBucle();
|
|
143
|
+
/**
|
|
144
|
+
* Archivos escritos en el turno. Solo sirve para saber si hay algo que
|
|
145
|
+
* verificar al cerrarlo: si el turno no toco el disco, no hay nada que
|
|
146
|
+
* comprobar y correr el compilador seria tiempo del usuario tirado.
|
|
147
|
+
*/
|
|
148
|
+
archivosTocados = new Set();
|
|
31
149
|
constructor(apiKey, baseURL, initialModel, systemPrompt) {
|
|
32
150
|
this.apiKey = apiKey;
|
|
33
151
|
this.baseURL = baseURL;
|
|
34
152
|
// Un unico sitio construye el cliente: asi la credencial de suscripcion y
|
|
35
153
|
// las cabeceras propias llegan igual desde las cuatro rutas.
|
|
36
154
|
this.client = createEngineClient(apiKey, "loop");
|
|
155
|
+
this.compactClient = createEngineClient(apiKey, "compact");
|
|
37
156
|
this.currentEspectro = initialModel;
|
|
38
157
|
this.tracker = new TokenTracker();
|
|
39
158
|
this.permissions = new PermissionManager("default", this.currentCwd);
|
|
40
159
|
this.hooks = new HookManager(this.currentCwd);
|
|
160
|
+
// Los subagentes ejecutan herramientas fuera de este bucle, y estos dos
|
|
161
|
+
// gestores no les llegan por ninguna firma. Sin registrarlos aqui, corren sin
|
|
162
|
+
// permisos, sin hooks y sin el bloqueo del modo plan.
|
|
163
|
+
registerToolGate(this.permissions, this.hooks);
|
|
41
164
|
this.messages.push({
|
|
42
165
|
role: "system",
|
|
43
166
|
content: systemPrompt,
|
|
@@ -73,11 +196,14 @@ export class AgentLoop {
|
|
|
73
196
|
content: "Genera el resumen ejecutivo del contexto de trabajo hasta el momento.",
|
|
74
197
|
},
|
|
75
198
|
];
|
|
76
|
-
const res = await this.
|
|
199
|
+
const res = await this.compactClient.chat.completions.create({
|
|
77
200
|
model: this.currentEspectro.underlyingModel,
|
|
78
201
|
messages: summaryPrompt,
|
|
79
202
|
temperature: 0.1,
|
|
80
203
|
});
|
|
204
|
+
// Resumir manda el historial ENTERO como prompt. Es dinero real y hasta
|
|
205
|
+
// ahora no lo apuntaba nadie: el pie seguia marcando el mismo porcentaje.
|
|
206
|
+
apuntarUso(res.usage, this.currentEspectro.underlyingModel, "compact");
|
|
81
207
|
const summary = res.choices[0]?.message?.content || "Contexto previo compactado.";
|
|
82
208
|
spinner.stop();
|
|
83
209
|
this.messages = [
|
|
@@ -98,41 +224,90 @@ export class AgentLoop {
|
|
|
98
224
|
return `Fallo al compactar historial: ${err?.message || String(err)}`;
|
|
99
225
|
}
|
|
100
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Tokens que OCUPA el historial ahora mismo, no los que se han gastado en el
|
|
229
|
+
* turno. Son dos numeros distintos y confundirlos era el fallo de abajo.
|
|
230
|
+
*/
|
|
101
231
|
lastTurnTokens = 0;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Compactacion automatica del contexto.
|
|
234
|
+
*
|
|
235
|
+
* Habia tres cosas mal, y cada una se comia el contexto por su lado:
|
|
236
|
+
*
|
|
237
|
+
* 1. El corte era por posicion fija: slice(1, -4) para lo que se resumia y
|
|
238
|
+
* slice(-4) para lo que se conservaba. Un turno que acaba en un bloque de
|
|
239
|
+
* cuatro herramientas deja la cola empezando por un mensaje "tool" cuya
|
|
240
|
+
* llamada se ha quedado en la mitad resumida. La API contesta 400 y el
|
|
241
|
+
* catch mudo lo tapaba: la sesion se rompia sin motivo visible, y siempre
|
|
242
|
+
* en las sesiones largas, que son las que llevan mas trabajo encima. El
|
|
243
|
+
* corte lo decide ahora la forma del historial, nunca una posicion.
|
|
244
|
+
*
|
|
245
|
+
* 2. El umbral solo se miraba al empezar el turno y sobre el dato del turno
|
|
246
|
+
* ANTERIOR. Un turno de veinte herramientas puede doblar el contexto sin
|
|
247
|
+
* que nadie lo compruebe, y el limite se choca a mitad de camino.
|
|
248
|
+
*
|
|
249
|
+
* 3. Resumir cuesta una llamada al modelo y pierde detalle. Vaciar las
|
|
250
|
+
* salidas de herramienta viejas no cuesta nada y casi siempre basta, asi
|
|
251
|
+
* que va primero.
|
|
252
|
+
*/
|
|
253
|
+
async autoCompactIfNeeded(tokensActuales = this.lastTurnTokens) {
|
|
254
|
+
// La ventana real es de 1.000.000 de tokens. Comprimir antes de acercarse
|
|
255
|
+
// al limite es tirar contexto bueno.
|
|
256
|
+
if (tokensActuales < COMPACT_THRESHOLD_TOKENS)
|
|
108
257
|
return false;
|
|
258
|
+
// Paso 1, gratis: vaciar las salidas de herramienta mas viejas. Un view_file
|
|
259
|
+
// de hace veinte pasos ocupa miles de tokens y ya no dice nada; las
|
|
260
|
+
// decisiones tomadas, que son lo que hay que conservar, estan en el texto.
|
|
261
|
+
const antes = estimarTokens(this.messages);
|
|
262
|
+
const podado = vaciarResultadosAntiguos(this.messages, RESULTADOS_INTACTOS);
|
|
263
|
+
if (podado.liberados > 0) {
|
|
264
|
+
this.messages = podado.mensajes;
|
|
265
|
+
const despues = estimarTokens(this.messages);
|
|
266
|
+
// La cifra absoluta buena la da la API en prompt_tokens; de la estimacion
|
|
267
|
+
// solo se usa la proporcion de lo que se acaba de quitar.
|
|
268
|
+
const proyectado = antes > 0 ? Math.round(tokensActuales * (despues / antes)) : tokensActuales;
|
|
269
|
+
this.lastTurnTokens = proyectado;
|
|
270
|
+
if (proyectado < COMPACT_THRESHOLD_TOKENS * 0.8) {
|
|
271
|
+
salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
|
|
272
|
+
chalk.gray(`Liberados ~${(antes - despues).toLocaleString()} tokens de salidas de herramientas antiguas. ` +
|
|
273
|
+
"La conversacion se conserva entera.\n"));
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
109
276
|
}
|
|
277
|
+
const spinner = new DynamicSpinner();
|
|
110
278
|
try {
|
|
111
279
|
const activeSystemPrompt = this.messages[0]?.role === "system" ? this.messages[0].content : "";
|
|
112
|
-
|
|
113
|
-
|
|
280
|
+
// El corte respeta siempre los pares llamada-respuesta; si el punto que
|
|
281
|
+
// tocaba parte un bloque, se conserva de mas.
|
|
282
|
+
const corte = corteSeguro(this.messages, COLA_INTACTA);
|
|
283
|
+
const cabeza = limpiarHuerfanos(this.messages.slice(1, corte));
|
|
284
|
+
if (cabeza.length < 4)
|
|
114
285
|
return false;
|
|
115
|
-
const
|
|
286
|
+
const cola = this.messages.slice(corte);
|
|
116
287
|
spinner.start("Auto-optimizando memoria del contexto");
|
|
117
288
|
const summaryPrompt = [
|
|
118
289
|
{
|
|
119
290
|
role: "system",
|
|
120
291
|
content: "Resume de forma ultra concisa los archivos modificados, decisiones tomadas, arquitectura y estado actual del proyecto para continuar trabajando sin perder el hilo.",
|
|
121
292
|
},
|
|
122
|
-
...
|
|
293
|
+
...cabeza,
|
|
123
294
|
{
|
|
124
295
|
role: "user",
|
|
125
296
|
content: "Genera el resumen ejecutivo del contexto de trabajo previo.",
|
|
126
297
|
},
|
|
127
298
|
];
|
|
128
|
-
const res = await this.
|
|
299
|
+
const res = await this.compactClient.chat.completions.create({
|
|
129
300
|
model: this.currentEspectro.underlyingModel,
|
|
130
301
|
messages: summaryPrompt,
|
|
131
302
|
temperature: 0.1,
|
|
132
303
|
});
|
|
304
|
+
// La llamada mas cara del agente: entra aqui con el contexto rozando el
|
|
305
|
+
// umbral (750.000 tokens) y lo manda entero. En Espectro 2.0 son ~0,99 $,
|
|
306
|
+
// el 85% del presupuesto semanal por defecto, que antes valian 0,00 $ en
|
|
307
|
+
// el contador local. De ahi el 402 por sorpresa.
|
|
308
|
+
apuntarUso(res.usage, this.currentEspectro.underlyingModel, "compact");
|
|
133
309
|
const summary = res.choices[0]?.message?.content || "Contexto previo optimizado.";
|
|
134
310
|
spinner.stop();
|
|
135
|
-
const recentMessages = this.messages.slice(-4);
|
|
136
311
|
this.messages = [
|
|
137
312
|
{ role: "system", content: activeSystemPrompt },
|
|
138
313
|
{
|
|
@@ -143,38 +318,186 @@ export class AgentLoop {
|
|
|
143
318
|
role: "assistant",
|
|
144
319
|
content: "Entendido. He retenido el contexto clave del proyecto y liberado memoria.",
|
|
145
320
|
},
|
|
146
|
-
...
|
|
321
|
+
...cola,
|
|
147
322
|
];
|
|
148
|
-
|
|
323
|
+
// Queda una estimacion hasta la siguiente respuesta de la API: mejor eso
|
|
324
|
+
// que arrastrar la cifra vieja, que dispararia otra compactacion seguida.
|
|
325
|
+
this.lastTurnTokens = estimarTokens(this.messages);
|
|
326
|
+
salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
|
|
149
327
|
chalk.gray(`Contexto optimizado automáticamente (${this.messages.length} mensajes limpios) para mantener alta velocidad y mínimo consumo de API.\n`));
|
|
150
328
|
return true;
|
|
151
329
|
}
|
|
152
|
-
catch {
|
|
330
|
+
catch (err) {
|
|
331
|
+
spinner.stop();
|
|
332
|
+
// El catch mudo era medio fallo: si el resumen no sale, el turno sigue con
|
|
333
|
+
// el contexto entero y el error de despues llega sin explicacion.
|
|
334
|
+
salida.linea(chalk.gray(` (no se pudo compactar el contexto: ${err?.message || String(err)})`));
|
|
153
335
|
return false;
|
|
154
336
|
}
|
|
155
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Verificacion del proyecto al cerrar un turno que escribio en disco.
|
|
340
|
+
*
|
|
341
|
+
* Devuelve el mensaje que hay que reinyectarle al modelo, o null si no hay
|
|
342
|
+
* nada que corregir (o si no toca verificar). El tope de vueltas evita el
|
|
343
|
+
* otro extremo: un agente peleandose solo con el compilador durante veinte
|
|
344
|
+
* iteraciones mientras el usuario mira.
|
|
345
|
+
*/
|
|
346
|
+
async verificarTrasEditar(reflexiones) {
|
|
347
|
+
if (this.archivosTocados.size === 0)
|
|
348
|
+
return null;
|
|
349
|
+
if (!verificacionAutomaticaActiva(this.currentCwd))
|
|
350
|
+
return null;
|
|
351
|
+
const cmd = detectarComando(this.currentCwd);
|
|
352
|
+
if (!cmd)
|
|
353
|
+
return null;
|
|
354
|
+
if (reflexiones >= MAX_REFLEXIONES) {
|
|
355
|
+
salida.linea(chalk.yellow(`\n⚠ ${mensajeDeRendicion(cmd.comando)}\n`));
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const spinner = new DynamicSpinner();
|
|
359
|
+
spinner.start(`Verificando (${cmd.comando})`);
|
|
360
|
+
let resultado;
|
|
361
|
+
try {
|
|
362
|
+
resultado = await verificar(cmd.comando, this.currentCwd);
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
spinner.stop();
|
|
366
|
+
}
|
|
367
|
+
if (resultado.ok) {
|
|
368
|
+
salida.linea(chalk.gray(` ✔ Verificado con ${cmd.comando}\n`));
|
|
369
|
+
// Lo verificado deja de estar pendiente: si el modelo vuelve a editar,
|
|
370
|
+
// se vuelve a comprobar; si no, no se comprueba dos veces lo mismo.
|
|
371
|
+
this.archivosTocados.clear();
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
salida.linea(chalk.yellow(`\n⚠ La verificacion fallo (${cmd.comando}). Se lo devuelvo al agente.\n`));
|
|
375
|
+
return mensajeDeVerificacion(cmd.comando, resultado.salida, reflexiones + 1);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Comprueba si esta llamada cierra un bucle y, si lo cierra, decide con el
|
|
379
|
+
* usuario que hacer. Devuelve el texto que se le pasa al modelo: el resultado
|
|
380
|
+
* tal cual, o la explicacion del atasco.
|
|
381
|
+
*
|
|
382
|
+
* Se llama DESPUES de ejecutar, no antes, porque la firma incluye el
|
|
383
|
+
* resultado: sin el, un sondeo legitimo (releer un log que cambia) parece un
|
|
384
|
+
* bucle. La tercera llamada repetida ya se ha pagado; lo que se evita son la
|
|
385
|
+
* cuarta y las veintiseis siguientes.
|
|
386
|
+
*/
|
|
387
|
+
/** Apunta que este turno ha escrito en disco, para verificarlo al cerrarlo. */
|
|
388
|
+
apuntarEscritura(herramienta, args, resultado) {
|
|
389
|
+
if (!HERRAMIENTAS_QUE_ESCRIBEN.has(herramienta))
|
|
390
|
+
return;
|
|
391
|
+
// Un edit_file que fallo no ha cambiado nada: verificar por eso seria
|
|
392
|
+
// hacerle perder tiempo al usuario por un error que ya se conto.
|
|
393
|
+
if (resultado.startsWith("Error") || resultado.includes("bloqueada:"))
|
|
394
|
+
return;
|
|
395
|
+
const ruta = args?.path || args?.destinationPath || args?.sourcePath;
|
|
396
|
+
if (typeof ruta === "string")
|
|
397
|
+
this.archivosTocados.add(ruta);
|
|
398
|
+
}
|
|
399
|
+
async vigilarBucle(herramienta, argumentos, resultado, antesDePreguntar) {
|
|
400
|
+
const aviso = this.detectorBucle.registrar(herramienta, argumentos, resultado);
|
|
401
|
+
if (!aviso)
|
|
402
|
+
return resultado;
|
|
403
|
+
antesDePreguntar?.();
|
|
404
|
+
const respuesta = await preguntarPorBucle(aviso);
|
|
405
|
+
if (respuesta.accion === "seguir") {
|
|
406
|
+
this.detectorBucle.perdonar(herramienta);
|
|
407
|
+
return resultado;
|
|
408
|
+
}
|
|
409
|
+
this.detectorBucle.olvidar();
|
|
410
|
+
return mensajeDeBucle(aviso, respuesta.indicacion);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Ejecuta un turno completo y CUENTA como acabo.
|
|
414
|
+
*
|
|
415
|
+
* Antes devolvia void: el motivo del corte se imprimia por pantalla y ahi se
|
|
416
|
+
* moria. Quien llamaba no podia distinguir "terminado" de "cuota agotada al
|
|
417
|
+
* primer intento", que es justo lo que necesita el modo no interactivo para
|
|
418
|
+
* salir con un codigo honesto.
|
|
419
|
+
*/
|
|
156
420
|
async runTask(userPrompt) {
|
|
421
|
+
let iterations = 0;
|
|
422
|
+
// COSTE: se paga cada llamada, asi que los prompts se suman.
|
|
423
|
+
let turnPromptTokens = 0;
|
|
424
|
+
// CONTEXTO: lo que ocupa el historial es el prompt de la ULTIMA llamada,
|
|
425
|
+
// no la suma de todas. Con la suma, un turno de diez llamadas parecia diez
|
|
426
|
+
// veces mas lleno de lo que estaba.
|
|
427
|
+
let lastCallPromptTokens = 0;
|
|
428
|
+
let turnCachedTokens = 0;
|
|
429
|
+
let turnCompletionTokens = 0;
|
|
430
|
+
/**
|
|
431
|
+
* Unica salida de runTask, y por eso el sitio donde se cierra el checkpoint
|
|
432
|
+
* de /undo.
|
|
433
|
+
*
|
|
434
|
+
* commitTurn vivia despues del while, asi que ninguna de las salidas
|
|
435
|
+
* anticipadas —Esc a mitad de turno, error del motor, cuota agotada— pasaba
|
|
436
|
+
* por el. Consecuencia doble y las dos malas: el usuario que interrumpia un
|
|
437
|
+
* turno que ya habia reescrito tres archivos leia "no hay acciones previas
|
|
438
|
+
* para deshacer" con el disco a medias; y esas instantaneas huerfanas se
|
|
439
|
+
* quedaban en currentTurnSnapshots (snapshotFile hace early-return si la
|
|
440
|
+
* clave ya existe, asi que ni se refrescaban) hasta colgarse del primer
|
|
441
|
+
* turno que terminara limpio, que al deshacerse revertia tambien los
|
|
442
|
+
* archivos del turno interrumpido anunciando otra descripcion.
|
|
443
|
+
*
|
|
444
|
+
* El motivo va en la descripcion: en el historial de /undo hay que poder
|
|
445
|
+
* distinguir un turno terminado de uno cortado.
|
|
446
|
+
*/
|
|
447
|
+
const parada = (motivo, detalle) => {
|
|
448
|
+
globalUndoManager.commitTurn(motivo === "completado" ? userPrompt : `${userPrompt} [${motivo}]`);
|
|
449
|
+
return {
|
|
450
|
+
exito: motivo === "completado",
|
|
451
|
+
motivo,
|
|
452
|
+
detalle,
|
|
453
|
+
turnos: iterations,
|
|
454
|
+
tokensEntrada: turnPromptTokens,
|
|
455
|
+
tokensSalida: turnCompletionTokens,
|
|
456
|
+
};
|
|
457
|
+
};
|
|
157
458
|
// Cuota ANTES de gastar. Si no queda margen, se dice y no se llama al motor:
|
|
158
459
|
// avisar despues de haber gastado no sirve de nada.
|
|
159
460
|
const cuota = checkQuota();
|
|
160
461
|
if (!cuota.allowed) {
|
|
161
|
-
|
|
462
|
+
const corte = mensajeDeCorte(cuota);
|
|
463
|
+
salida.linea(chalk.yellow(`\n⏸ ${corte}`) +
|
|
162
464
|
chalk.gray("\n Tu trabajo y tu contexto siguen aqui: al liberarse, continuas donde lo dejaste.\n"));
|
|
163
|
-
return;
|
|
465
|
+
return parada("cuota", corte);
|
|
164
466
|
}
|
|
165
467
|
// Check and execute auto-compaction if context threshold is exceeded
|
|
166
468
|
await this.autoCompactIfNeeded();
|
|
469
|
+
// El usuario ha dicho algo nuevo: lo que se repitiera en el turno anterior
|
|
470
|
+
// ya no cuenta, y lo que perdono tampoco tiene por que valer aqui.
|
|
471
|
+
this.detectorBucle.reiniciar();
|
|
167
472
|
const startTime = Date.now();
|
|
168
473
|
this.messages.push({
|
|
169
474
|
role: "user",
|
|
170
475
|
content: userPrompt,
|
|
171
476
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
let
|
|
175
|
-
|
|
477
|
+
// Distingue "el modelo cerro su turno" de "se acabaron las iteraciones",
|
|
478
|
+
// que por fuera se parecen y no valen lo mismo.
|
|
479
|
+
let completado = false;
|
|
480
|
+
/** Un desbordamiento de contexto se rescata compactando, pero una sola vez. */
|
|
481
|
+
let yaCompactadoPorDesbordamiento = false;
|
|
482
|
+
/** Vueltas gastadas en corregir lo que dijo el verificador del proyecto. */
|
|
483
|
+
let reflexiones = 0;
|
|
484
|
+
/** Veces seguidas que la respuesta se corto por longitud sin hacer nada. */
|
|
485
|
+
let cortesPorLongitud = 0;
|
|
486
|
+
/** Veces seguidas que el turno acabo sin una sola letra que ensenar. */
|
|
487
|
+
let respuestasVacias = 0;
|
|
488
|
+
// Se limpia aqui: lo que se edito en el turno anterior ya se verifico (o no
|
|
489
|
+
// se iba a verificar nunca).
|
|
490
|
+
this.archivosTocados.clear();
|
|
176
491
|
while (iterations < this.maxIterations) {
|
|
177
492
|
iterations++;
|
|
493
|
+
// El contexto tambien crece dentro del turno: veinte herramientas con
|
|
494
|
+
// sus salidas pueden pasar del umbral sin que termine el turno. Mirarlo
|
|
495
|
+
// solo al principio era llegar tarde.
|
|
496
|
+
if (lastCallPromptTokens > 0) {
|
|
497
|
+
const compactado = await this.autoCompactIfNeeded(lastCallPromptTokens);
|
|
498
|
+
if (compactado)
|
|
499
|
+
lastCallPromptTokens = this.lastTurnTokens;
|
|
500
|
+
}
|
|
178
501
|
// Near iteration limit notice
|
|
179
502
|
if (iterations === this.maxIterations - 2) {
|
|
180
503
|
this.messages.push({
|
|
@@ -203,7 +526,7 @@ export class AgentLoop {
|
|
|
203
526
|
}, { signal }), {
|
|
204
527
|
onRetry: (attempt, delay, reason) => {
|
|
205
528
|
spinner.stop();
|
|
206
|
-
|
|
529
|
+
salida.linea(chalk.yellow(`\n⟳ El motor respondio ${reason}. Reintento ${attempt} en ${(delay / 1000).toFixed(1)}s...`));
|
|
207
530
|
spinner.start();
|
|
208
531
|
},
|
|
209
532
|
});
|
|
@@ -212,24 +535,38 @@ export class AgentLoop {
|
|
|
212
535
|
spinner.stop();
|
|
213
536
|
watcher.stop();
|
|
214
537
|
if (isAbortError(err) || watcher.interrupted) {
|
|
215
|
-
|
|
216
|
-
return;
|
|
538
|
+
salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
|
|
539
|
+
return parada("interrumpido");
|
|
540
|
+
}
|
|
541
|
+
// El contexto no cabe. Reintentar la misma peticion es imposible por
|
|
542
|
+
// definicion, pero compactar y volver a intentarlo si tiene arreglo, y
|
|
543
|
+
// es justo lo que el usuario esperaria en vez de perder el turno. Una
|
|
544
|
+
// sola vez: si tras compactar sigue sin caber, el problema es otro.
|
|
545
|
+
if (isContextOverflow(err) && !yaCompactadoPorDesbordamiento) {
|
|
546
|
+
yaCompactadoPorDesbordamiento = true;
|
|
547
|
+
salida.linea(chalk.yellow("\n⚠ La peticion no cabia en el contexto. Compactando y reintentando..."));
|
|
548
|
+
const activo = this.messages[0]?.role === "system" ? this.messages[0].content : "";
|
|
549
|
+
await this.compactHistory(activo);
|
|
550
|
+
iterations--; // este intento no llego al modelo: no cuenta como vuelta
|
|
551
|
+
continue;
|
|
217
552
|
}
|
|
218
553
|
// Un 402 (cuota agotada) o un 401 (licencia caida) no son "fallos del
|
|
219
554
|
// motor" y ademas no se reintentan nunca: decirlo como tal confunde y
|
|
220
555
|
// hace que el usuario piense que el producto esta roto.
|
|
221
556
|
const claro = explicarErrorDelMotor(err);
|
|
222
557
|
if (claro) {
|
|
223
|
-
|
|
558
|
+
salida.linea(chalk.yellow(`
|
|
224
559
|
⏸ ${claro}
|
|
225
560
|
`));
|
|
226
|
-
return;
|
|
561
|
+
return parada(motivoDeParadaDelError(err), claro);
|
|
227
562
|
}
|
|
228
|
-
|
|
229
|
-
|
|
563
|
+
const fallo = `Error en el motor tras varios reintentos: ${err?.message || String(err)}`;
|
|
564
|
+
renderError(fallo);
|
|
565
|
+
return parada("error-motor", fallo);
|
|
230
566
|
}
|
|
231
567
|
let content = "";
|
|
232
568
|
let reasoning = "";
|
|
569
|
+
let motivoDeCorte = null;
|
|
233
570
|
const rawToolCalls = [];
|
|
234
571
|
let isFirstChunk = true;
|
|
235
572
|
const reasoningStream = new ReasoningStream();
|
|
@@ -272,10 +609,17 @@ export class AgentLoop {
|
|
|
272
609
|
rawToolCalls[idx].function.arguments += tc.function.arguments;
|
|
273
610
|
}
|
|
274
611
|
}
|
|
612
|
+
// El motivo por el que el modelo dejo de hablar. Sin esto, quedarse
|
|
613
|
+
// sin presupuesto de salida es indistinguible de terminar el turno.
|
|
614
|
+
const corte = chunk.choices?.[0]?.finish_reason;
|
|
615
|
+
if (corte)
|
|
616
|
+
motivoDeCorte = corte;
|
|
275
617
|
// Track streaming usage metrics
|
|
276
618
|
if (chunk.usage) {
|
|
277
619
|
this.tracker.addUsage(chunk.usage);
|
|
278
620
|
turnPromptTokens += chunk.usage.prompt_tokens || 0;
|
|
621
|
+
if (chunk.usage.prompt_tokens)
|
|
622
|
+
lastCallPromptTokens = chunk.usage.prompt_tokens;
|
|
279
623
|
turnCompletionTokens += chunk.usage.completion_tokens || 0;
|
|
280
624
|
turnCachedTokens += chunk.usage.prompt_tokens_details?.cached_tokens || 0;
|
|
281
625
|
}
|
|
@@ -292,18 +636,19 @@ export class AgentLoop {
|
|
|
292
636
|
if (content.trim()) {
|
|
293
637
|
this.messages.push({ role: "assistant", content });
|
|
294
638
|
}
|
|
295
|
-
|
|
296
|
-
return;
|
|
639
|
+
salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
|
|
640
|
+
return parada("interrumpido");
|
|
297
641
|
}
|
|
298
642
|
const claroStream = explicarErrorDelMotor(streamErr);
|
|
299
643
|
if (claroStream) {
|
|
300
|
-
|
|
644
|
+
salida.linea(chalk.yellow(`
|
|
301
645
|
⏸ ${claroStream}
|
|
302
646
|
`));
|
|
303
|
-
return;
|
|
647
|
+
return parada(motivoDeParadaDelError(streamErr), claroStream);
|
|
304
648
|
}
|
|
305
|
-
|
|
306
|
-
|
|
649
|
+
const falloStream = `Error en streaming: ${streamErr?.message || String(streamErr)}`;
|
|
650
|
+
renderError(falloStream);
|
|
651
|
+
return parada("error-motor", falloStream);
|
|
307
652
|
}
|
|
308
653
|
spinner.stop();
|
|
309
654
|
reasoningStream.flush();
|
|
@@ -312,16 +657,56 @@ export class AgentLoop {
|
|
|
312
657
|
watcher.stop();
|
|
313
658
|
if (content.trim())
|
|
314
659
|
this.messages.push({ role: "assistant", content });
|
|
315
|
-
|
|
316
|
-
return;
|
|
660
|
+
salida.linea(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
|
|
661
|
+
return parada("interrumpido");
|
|
317
662
|
}
|
|
318
663
|
// Filter valid accumulated tool calls
|
|
319
664
|
const toolCalls = rawToolCalls.filter((tc) => tc && tc.function && tc.function.name);
|
|
665
|
+
// El modelo se quedo sin presupuesto de salida y no llego a pedir nada.
|
|
666
|
+
//
|
|
667
|
+
// Esto se veia EXACTAMENTE igual que un turno terminado: sin llamadas a
|
|
668
|
+
// herramienta, el bucle daba el turno por cerrado, imprimia el pie y
|
|
669
|
+
// volvia al prompt. El usuario pedia "creame un juego en un archivo html",
|
|
670
|
+
// el modelo gastaba los 16.384 tokens razonando el diseno, se cortaba a
|
|
671
|
+
// mitad de frase, y no se creaba ningun archivo ni se decia por que.
|
|
672
|
+
//
|
|
673
|
+
// Se le devuelve el aviso y se le pide que actue, con tope: si a la
|
|
674
|
+
// segunda sigue sin hacer nada, el problema no es el presupuesto.
|
|
675
|
+
if (motivoDeCorte === "length" && toolCalls.length === 0) {
|
|
676
|
+
cortesPorLongitud++;
|
|
677
|
+
if (cortesPorLongitud > MAX_CORTES_POR_LONGITUD) {
|
|
678
|
+
const aviso = `La respuesta se corto por longitud ${cortesPorLongitud} veces seguidas sin llegar a ` +
|
|
679
|
+
`hacer nada. Prueba a pedirlo por partes.`;
|
|
680
|
+
salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
|
|
681
|
+
if (content.trim())
|
|
682
|
+
this.messages.push({ role: "assistant", content });
|
|
683
|
+
return parada("limite-iteraciones", aviso);
|
|
684
|
+
}
|
|
685
|
+
salida.linea(chalk.yellow("\n ⚠ La respuesta se corto por longitud antes de hacer nada. Pidiendole que vaya al grano...\n"));
|
|
686
|
+
if (content.trim())
|
|
687
|
+
this.messages.push({ role: "assistant", content });
|
|
688
|
+
this.messages.push({
|
|
689
|
+
role: "user",
|
|
690
|
+
content: "Tu respuesta anterior se corto porque agoto el presupuesto de salida mientras razonabas, " +
|
|
691
|
+
"y no llegaste a usar ninguna herramienta: no se ha hecho nada todavia. Deja de planificar " +
|
|
692
|
+
"y actua ya. Si lo que hay que escribir es largo, empieza con write_file y sigue con " +
|
|
693
|
+
'write_file en modo="anadir", en trozos de menos de 400 lineas.',
|
|
694
|
+
});
|
|
695
|
+
// Sin esto se queda un vigilante de teclado vivo por cada corte, y cada
|
|
696
|
+
// uno vuelve a apuntarse como oyente: la misma tecla se procesa tantas
|
|
697
|
+
// veces como vigilantes haya, y el teclado deja de responder como
|
|
698
|
+
// deberia. La regla es: de este bucle no se sale sin soltar el teclado.
|
|
699
|
+
watcher.stop();
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
320
702
|
if (toolCalls.length > 0) {
|
|
321
703
|
this.messages.push({
|
|
322
704
|
role: "assistant",
|
|
323
705
|
content: content || null,
|
|
324
|
-
|
|
706
|
+
// Sanear NO es cosmetico: unos argumentos cortados guardados tal cual
|
|
707
|
+
// hacen que la API conteste 400 en TODAS las peticiones siguientes, y
|
|
708
|
+
// la sesion queda inservible hasta un /clear. Ver sanearLlamada().
|
|
709
|
+
tool_calls: toolCalls.map(sanearLlamada),
|
|
325
710
|
});
|
|
326
711
|
const toolResults = [];
|
|
327
712
|
// Las llamadas independientes de solo lectura se lanzan a la vez. Cuando
|
|
@@ -330,40 +715,77 @@ export class AgentLoop {
|
|
|
330
715
|
const names = toolCalls.map((tc) => tc.function.name);
|
|
331
716
|
const parallel = canRunInParallel(names) &&
|
|
332
717
|
!toolCalls.some((tc) => {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return this.permissions.requiresConfirmation(tc.function.name,
|
|
718
|
+
const parsed = parsearArgumentos(tc.function.arguments);
|
|
719
|
+
// Unos argumentos rotos se resuelven en serie, donde hay sitio para
|
|
720
|
+
// explicarselo al modelo.
|
|
721
|
+
if (!parsed.ok)
|
|
722
|
+
return true;
|
|
723
|
+
return this.permissions.requiresConfirmation(tc.function.name, parsed.args, this.currentCwd);
|
|
339
724
|
});
|
|
340
725
|
if (parallel) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
726
|
+
// Esta rama ejecutaba directamente, sin pasar por hooks ni por deny.
|
|
727
|
+
// Un PreToolUse que prohibe leer secretos se saltaba en silencio en
|
|
728
|
+
// cuanto el modelo pedia dos lecturas en el mismo turno —cosa que el
|
|
729
|
+
// propio prompt del sistema fomenta—, y los PostToolUse (linters,
|
|
730
|
+
// politicas) tampoco corrian. Fallaba callado y hacia el lado
|
|
731
|
+
// inseguro, con lo que la politica del proyecto valia o no segun
|
|
732
|
+
// cuantas herramientas pidiera el modelo a la vez.
|
|
733
|
+
//
|
|
734
|
+
// La puerta es la misma que usan la via secuencial y los subagentes
|
|
735
|
+
// (toolGate.ts). Se autoriza en serie —los hooks son procesos, y un
|
|
736
|
+
// dialogo simultaneo por duplicado no se puede ni leer— y se
|
|
737
|
+
// paraleliza solo la ejecucion, que es lo que se queria ganar.
|
|
738
|
+
const autorizadas = [];
|
|
739
|
+
const enOrden = toolCalls.map(() => null);
|
|
740
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
741
|
+
const tc = toolCalls[i];
|
|
742
|
+
const parsed = parsearArgumentos(tc.function.arguments);
|
|
743
|
+
if (!parsed.ok) {
|
|
744
|
+
const motivo = mensajeDeArgumentosRotos(tc.function.name);
|
|
745
|
+
renderToolStart(tc.function.name, "argumentos incompletos");
|
|
746
|
+
renderToolError(tc.function.name, motivo);
|
|
747
|
+
enOrden[i] = { role: "tool", tool_call_id: tc.id, content: motivo };
|
|
748
|
+
continue;
|
|
345
749
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
750
|
+
const a = parsed.args;
|
|
751
|
+
const decision = await autorizarHerramienta(tc.function.name, a, this.currentCwd);
|
|
752
|
+
if (!decision.permitida) {
|
|
753
|
+
const motivo = decision.motivo || "Accion no autorizada.";
|
|
754
|
+
renderToolStart(tc.function.name, getToolSummary(tc.function.name, a));
|
|
755
|
+
renderToolError(tc.function.name, motivo);
|
|
756
|
+
enOrden[i] = { role: "tool", tool_call_id: tc.id, content: motivo };
|
|
757
|
+
continue;
|
|
353
758
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
759
|
+
autorizadas.push({ tc, args: decision.args, pos: i });
|
|
760
|
+
}
|
|
761
|
+
for (const { tc, args } of autorizadas) {
|
|
762
|
+
renderToolStart(tc.function.name, getToolSummary(tc.function.name, args));
|
|
763
|
+
}
|
|
764
|
+
const settled = await Promise.all(autorizadas.map(async ({ tc, args, pos }) => {
|
|
765
|
+
const r = await executeToolCall(tc.function.name, args, this.currentCwd, this.apiKey, this.currentEspectro.underlyingModel);
|
|
766
|
+
return { tc, args, pos, result: r.result };
|
|
357
767
|
}));
|
|
358
|
-
for (const { tc, result } of settled) {
|
|
768
|
+
for (const { tc, args, pos, result } of settled) {
|
|
359
769
|
renderToolSuccess(tc.function.name, result);
|
|
360
|
-
|
|
770
|
+
const aviso = await notificarPostHerramienta(tc.function.name, args, result, this.currentCwd);
|
|
771
|
+
this.apuntarEscritura(tc.function.name, args, result);
|
|
772
|
+
const texto = await this.vigilarBucle(tc.function.name, tc.function.arguments, result);
|
|
773
|
+
enOrden[pos] = {
|
|
774
|
+
role: "tool",
|
|
775
|
+
tool_call_id: tc.id,
|
|
776
|
+
content: aviso ? `${texto}\n\n[Hook PostToolUse]: ${aviso}` : texto,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
// En el orden en que las pidio el modelo, no en el que acabaron.
|
|
780
|
+
for (const r of enOrden) {
|
|
781
|
+
if (r)
|
|
782
|
+
toolResults.push(r);
|
|
361
783
|
}
|
|
362
784
|
toolResults.forEach((r) => this.messages.push(r));
|
|
363
785
|
watcher.stop();
|
|
364
786
|
if (watcher.interrupted) {
|
|
365
|
-
|
|
366
|
-
return;
|
|
787
|
+
salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
|
|
788
|
+
return parada("interrumpido");
|
|
367
789
|
}
|
|
368
790
|
continue;
|
|
369
791
|
}
|
|
@@ -380,12 +802,18 @@ export class AgentLoop {
|
|
|
380
802
|
}
|
|
381
803
|
const fnName = tc.function.name;
|
|
382
804
|
let fnArgs = {};
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
805
|
+
const parsed = parsearArgumentos(tc.function.arguments);
|
|
806
|
+
if (!parsed.ok) {
|
|
807
|
+
// Antes esto era `catch { fnArgs = {} }`: la herramienta se
|
|
808
|
+
// ejecutaba igual, sin argumentos. Un write_file sin ruta ni
|
|
809
|
+
// contenido, y respondiendo como si nada hubiera pasado.
|
|
810
|
+
const motivo = mensajeDeArgumentosRotos(fnName);
|
|
811
|
+
renderToolStart(fnName, "argumentos incompletos");
|
|
812
|
+
renderToolError(fnName, motivo);
|
|
813
|
+
toolResults.push({ role: "tool", tool_call_id: tc.id, content: motivo });
|
|
814
|
+
continue;
|
|
388
815
|
}
|
|
816
|
+
fnArgs = parsed.args;
|
|
389
817
|
// PreToolUse: el usuario puede vetar, autorizar o reescribir los
|
|
390
818
|
// argumentos ANTES de que se ejecute nada. Va antes del dialogo de
|
|
391
819
|
// permiso porque un veto no deberia ni llegar a preguntarse.
|
|
@@ -404,7 +832,7 @@ export class AgentLoop {
|
|
|
404
832
|
fnArgs = { ...fnArgs, ...pre.updatedInput };
|
|
405
833
|
}
|
|
406
834
|
if (pre.systemMessage) {
|
|
407
|
-
|
|
835
|
+
salida.linea(chalk.yellow(`\n⚑ ${pre.systemMessage}`));
|
|
408
836
|
}
|
|
409
837
|
// Modo plan: bloqueo duro, no un dialogo. Lo que el modo promete es
|
|
410
838
|
// solo lectura, asi que aqui no hay nada que preguntar.
|
|
@@ -419,9 +847,25 @@ export class AgentLoop {
|
|
|
419
847
|
});
|
|
420
848
|
continue;
|
|
421
849
|
}
|
|
850
|
+
// Un patron de denyPatterns corta aqui, antes de todo lo demas: no se
|
|
851
|
+
// pregunta, no lo salva el --yes, no lo salva el modo bypass y no lo
|
|
852
|
+
// salva un hook que diga "allow". Hasta ahora esa lista se cargaba y se
|
|
853
|
+
// volvia a guardar sin que nadie la consultara nunca.
|
|
854
|
+
const denegado = this.permissions.deniedBy(fnName, fnArgs);
|
|
855
|
+
if (denegado) {
|
|
856
|
+
const motivo = `Bloqueado por denyPatterns ("${denegado}") en la configuracion del usuario.`;
|
|
857
|
+
renderToolStart(fnName, getToolSummary(fnName, fnArgs));
|
|
858
|
+
renderToolError(fnName, motivo);
|
|
859
|
+
toolResults.push({
|
|
860
|
+
role: "tool",
|
|
861
|
+
tool_call_id: tc.id,
|
|
862
|
+
content: `${motivo} No hay forma de aprobarlo desde aqui: el usuario lo prohibio en su settings.json. Busca otra via o preguntale.`,
|
|
863
|
+
});
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
422
866
|
// Permission gate
|
|
423
867
|
const saltaPermiso = pre.permissionDecision === "allow";
|
|
424
|
-
if (!saltaPermiso && this.permissions.requiresConfirmation(fnName, fnArgs)) {
|
|
868
|
+
if (!saltaPermiso && this.permissions.requiresConfirmation(fnName, fnArgs, this.currentCwd)) {
|
|
425
869
|
watcher.stop();
|
|
426
870
|
const permResult = await askToolPermission(fnName, fnArgs, this.currentCwd);
|
|
427
871
|
if (!permResult.approved) {
|
|
@@ -436,8 +880,7 @@ export class AgentLoop {
|
|
|
436
880
|
continue;
|
|
437
881
|
}
|
|
438
882
|
if (permResult.rememberPattern) {
|
|
439
|
-
|
|
440
|
-
this.permissions.saveAllowPattern(pat);
|
|
883
|
+
this.recordarPermiso(fnName, fnArgs);
|
|
441
884
|
}
|
|
442
885
|
}
|
|
443
886
|
const summary = getToolSummary(fnName, fnArgs);
|
|
@@ -453,52 +896,160 @@ export class AgentLoop {
|
|
|
453
896
|
// PostToolUse: sirve para validar lo que acaba de pasar (linters,
|
|
454
897
|
// tests, politicas) y devolverle al modelo un aviso accionable.
|
|
455
898
|
const post = await this.hooks.run("PostToolUse", { tool_name: fnName, tool_input: fnArgs, tool_response: result.slice(0, 4000) }, fnName);
|
|
899
|
+
// El systemMessage de PreToolUse si se imprimia y el de PostToolUse
|
|
900
|
+
// no: el mismo campo, del mismo hook, se veia o no segun el evento.
|
|
901
|
+
if (post.systemMessage) {
|
|
902
|
+
salida.linea(chalk.yellow(`\n⚑ ${post.systemMessage}`));
|
|
903
|
+
}
|
|
904
|
+
// El dialogo del bucle usa stdin, igual que el de permisos: hay que
|
|
905
|
+
// soltarle el terminal al vigilante de Esc. Pero solo si de verdad se
|
|
906
|
+
// va a preguntar: pararlo en cada herramienta dejaria el resto del
|
|
907
|
+
// lote sin Esc, que es justo lo que no se quiere.
|
|
908
|
+
this.apuntarEscritura(fnName, fnArgs, result);
|
|
909
|
+
const texto = await this.vigilarBucle(fnName, tc.function.arguments, result, () => watcher.stop());
|
|
456
910
|
toolResults.push({
|
|
457
911
|
role: "tool",
|
|
458
912
|
tool_call_id: tc.id,
|
|
459
|
-
content: post.additionalContext ? `${
|
|
460
|
-
[Hook PostToolUse]: ${post.additionalContext}` :
|
|
913
|
+
content: post.additionalContext ? `${texto}\n\n
|
|
914
|
+
[Hook PostToolUse]: ${post.additionalContext}` : texto,
|
|
461
915
|
});
|
|
462
916
|
}
|
|
463
917
|
// Push all tool results in a single turn
|
|
464
918
|
toolResults.forEach((r) => this.messages.push(r));
|
|
465
919
|
watcher.stop();
|
|
466
920
|
if (watcher.interrupted) {
|
|
467
|
-
|
|
468
|
-
return;
|
|
921
|
+
salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
|
|
922
|
+
return parada("interrumpido");
|
|
469
923
|
}
|
|
470
924
|
continue;
|
|
471
925
|
}
|
|
472
926
|
watcher.stop();
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
927
|
+
// EL TURNO ACABA SIN UNA SOLA LETRA QUE ENSENAR
|
|
928
|
+
//
|
|
929
|
+
// Salio de un caso real: cuatro ordenes seguidas ("puedes conectarte a mi
|
|
930
|
+
// vscode?", "si?", "puedes?", "a mi vscode") contestadas todas con esto:
|
|
931
|
+
//
|
|
932
|
+
// 💭 penso 1s
|
|
933
|
+
// ⚡ 4.8s · 0 tokens salida · $0.00000 USD · 100% contexto libre
|
|
934
|
+
//
|
|
935
|
+
// Ni un caracter en pantalla, ni un error, ni un aviso. Desde fuera es
|
|
936
|
+
// identico a que el agente te ignore, y el usuario reescribe la orden
|
|
937
|
+
// pensando que no llego. Encima cada una de esas cuatro se llevo por
|
|
938
|
+
// delante un hueco de peticion en el proxy, asi que la quinta choco con
|
|
939
|
+
// "Tienes 4 peticiones en curso": el silencio no solo confunde, ademas
|
|
940
|
+
// deja al usuario sin servicio.
|
|
941
|
+
//
|
|
942
|
+
// Es el mismo agujero que el corte por longitud, en su otra cara: el
|
|
943
|
+
// codigo no distingue "el modelo termino" de "no vino nada". Sin
|
|
944
|
+
// herramientas y sin texto, el bucle daba el turno por hecho, imprimia el
|
|
945
|
+
// pie y volvia al prompt.
|
|
946
|
+
if (!content.trim()) {
|
|
947
|
+
respuestasVacias++;
|
|
948
|
+
// Se reintenta UNA vez, no mas, y aqui la moderacion importa: cada
|
|
949
|
+
// intento consume un hueco de peticion de la licencia, y solo hay
|
|
950
|
+
// cuatro. Insistir con un motor que no contesta es la forma mas rapida
|
|
951
|
+
// de quedarse sin ninguno.
|
|
952
|
+
if (respuestasVacias > MAX_RESPUESTAS_VACIAS) {
|
|
953
|
+
const aviso = `El motor devolvio ${respuestasVacias} respuestas vacias seguidas. ` +
|
|
954
|
+
"No es tu conexion ni tu orden: no esta llegando nada del modelo. " +
|
|
955
|
+
"Espera un momento y reintenta, o /clear si venias de una conversacion larga.";
|
|
956
|
+
salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
|
|
957
|
+
return parada("error-motor", aviso);
|
|
958
|
+
}
|
|
959
|
+
salida.linea(chalk.gray("\n ⚠ El motor no devolvio nada. Reintentando una vez…\n"));
|
|
476
960
|
this.messages.push({
|
|
477
|
-
role: "
|
|
478
|
-
content
|
|
961
|
+
role: "user",
|
|
962
|
+
content: "Tu respuesta anterior llego vacia: ni texto ni llamadas a herramienta. " +
|
|
963
|
+
"El usuario no ha visto absolutamente nada. Contesta a lo que te pidio, " +
|
|
964
|
+
"aunque sea para decir que no puedes hacerlo o que no lo has entendido.",
|
|
479
965
|
});
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
// Una respuesta con contenido cierra la racha: lo que cuenta son las
|
|
969
|
+
// vacias SEGUIDAS, no las de toda la sesion.
|
|
970
|
+
respuestasVacias = 0;
|
|
971
|
+
// If text content was printed, ensure newline and record assistant message
|
|
972
|
+
salida.crudo("\n\n");
|
|
973
|
+
this.messages.push({
|
|
974
|
+
role: "assistant",
|
|
975
|
+
content,
|
|
976
|
+
});
|
|
977
|
+
// El modelo da el turno por cerrado. Si toco archivos y el proyecto tiene
|
|
978
|
+
// verificacion automatica activada, se comprueba ANTES de creerselo: un
|
|
979
|
+
// error de tipos que nadie mira se queda en el disco y los turnos
|
|
980
|
+
// siguientes escriben encima.
|
|
981
|
+
const pendiente = await this.verificarTrasEditar(reflexiones);
|
|
982
|
+
if (pendiente) {
|
|
983
|
+
reflexiones++;
|
|
984
|
+
this.messages.push({ role: "user", content: pendiente });
|
|
985
|
+
continue;
|
|
480
986
|
}
|
|
987
|
+
completado = true;
|
|
481
988
|
break;
|
|
482
989
|
}
|
|
483
990
|
const durationMs = Date.now() - startTime;
|
|
484
991
|
const { formattedCost, totalCost } = this.tracker.calculateCost(turnPromptTokens, turnCachedTokens, turnCompletionTokens, this.currentEspectro.underlyingModel);
|
|
485
992
|
const approxTotalTokens = turnPromptTokens + turnCompletionTokens;
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
993
|
+
// Lo que ocupa el historial es el prompt de la ultima llamada. Sumar el de
|
|
994
|
+
// todas las iteraciones inflaba la cuenta hasta disparar la compactacion
|
|
995
|
+
// con el contexto medio vacio, y hacia mentir al porcentaje del pie.
|
|
996
|
+
this.lastTurnTokens = lastCallPromptTokens || approxTotalTokens;
|
|
997
|
+
const used = lastCallPromptTokens || turnPromptTokens || approxTotalTokens;
|
|
490
998
|
const contextPercent = Math.max(1, 100 - Math.round((used / CONTEXT_WINDOW) * 100));
|
|
491
|
-
|
|
492
|
-
//
|
|
999
|
+
// Se apunta lo gastado en este turno para las ventanas deslizantes. El
|
|
1000
|
+
// checkpoint de /undo ya no se cierra aqui, sino en parada(): este punto
|
|
1001
|
+
// solo lo alcanzaban los turnos que terminaban limpios.
|
|
493
1002
|
recordUsage(totalCost);
|
|
1003
|
+
// Los subagentes, la vision y la compactacion ya se apuntaron solos segun
|
|
1004
|
+
// gastaban. Se dice en voz alta porque si no el pie parece contradecirse:
|
|
1005
|
+
// marca 0,0012 $ de turno y la insignia semanal sube diez veces mas.
|
|
1006
|
+
const auxiliar = tomarGastoAuxiliar();
|
|
494
1007
|
const tras = checkQuota();
|
|
495
1008
|
renderFooter(formattedCost, turnCompletionTokens, turnCachedTokens, this.currentEspectro.name, durationMs, contextPercent, quotaBadge(tras));
|
|
1009
|
+
if (auxiliar.total > 0) {
|
|
1010
|
+
salida.linea(chalk.gray(` + $${auxiliar.total.toFixed(4)} USD fuera del bucle (${auxiliar.rutas}), ya contados en la cuota.\n`));
|
|
1011
|
+
}
|
|
496
1012
|
// Aviso al acercarse, no solo al chocar: quedarse a medias sin previo aviso
|
|
497
1013
|
// es lo que hace que la gente cancele.
|
|
498
1014
|
if (tras.allowed && tras.spentWeekUsd >= tras.config.weeklyBudgetUsd * 0.8) {
|
|
499
1015
|
const queda = Math.max(0, tras.config.weeklyBudgetUsd - tras.spentWeekUsd);
|
|
500
|
-
|
|
1016
|
+
salida.linea(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
|
|
1017
|
+
}
|
|
1018
|
+
if (!completado) {
|
|
1019
|
+
// Se agotaron las iteraciones con trabajo a medias. En interactivo se ve
|
|
1020
|
+
// porque el ultimo mensaje queda cortado; en CI no lo ve nadie.
|
|
1021
|
+
const aviso = `El turno se agoto en el limite de ${this.maxIterations} iteraciones sin cerrar la tarea.`;
|
|
1022
|
+
salida.linea(chalk.yellow(` ⚠ ${aviso}\n`));
|
|
1023
|
+
return parada("limite-iteraciones", aviso);
|
|
1024
|
+
}
|
|
1025
|
+
return parada("completado");
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Guarda lo que el usuario acaba de aprobar con "no preguntar mas".
|
|
1029
|
+
*
|
|
1030
|
+
* Aqui se hacia command.split(" ")[0] y se guardaba solo el binario. Aprobar
|
|
1031
|
+
* un "git status" dejaba el patron "git" escrito en .chocolatito/settings.json
|
|
1032
|
+
* y, como el allow autoriza por prefijo, a partir de ese momento pasaban sin
|
|
1033
|
+
* dialogo "git push --force" y "git reset --hard", en esa sesion y en todas
|
|
1034
|
+
* las siguientes. Un solo si de mas y el proyecto se queda sin barrera.
|
|
1035
|
+
*
|
|
1036
|
+
* Ahora se guarda la firma del comando (binario mas subcomando) y, si el
|
|
1037
|
+
* comando es de los destructivos, no se guarda nada: eso se pregunta siempre,
|
|
1038
|
+
* y decirlo en voz alta es mejor que escribir un patron que no serviria.
|
|
1039
|
+
*/
|
|
1040
|
+
recordarPermiso(fnName, fnArgs) {
|
|
1041
|
+
const command = typeof fnArgs?.command === "string" ? fnArgs.command : "";
|
|
1042
|
+
if (command) {
|
|
1043
|
+
if (isDangerousCommand(command)) {
|
|
1044
|
+
salida.linea(chalk.gray(" (este comando es destructivo: se seguira preguntando cada vez, no se puede recordar)\n"));
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const firma = commandSignature(command);
|
|
1048
|
+
if (firma)
|
|
1049
|
+
this.permissions.saveAllowPattern(`${fnName}:${firma}`);
|
|
1050
|
+
return;
|
|
501
1051
|
}
|
|
1052
|
+
this.permissions.saveAllowPattern(fnArgs?.path || fnName);
|
|
502
1053
|
}
|
|
503
1054
|
}
|
|
504
1055
|
function getToolSummary(name, args) {
|