chocolatito-code 1.1.0 → 1.3.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.
Files changed (55) hide show
  1. package/README.md +66 -0
  2. package/dist/agent/huecos.d.ts +58 -0
  3. package/dist/agent/huecos.js +81 -0
  4. package/dist/agent/loop.js +218 -41
  5. package/dist/agent/salidaDelAgente.d.ts +43 -0
  6. package/dist/agent/salidaDelAgente.js +39 -0
  7. package/dist/agent/toolGate.js +6 -1
  8. package/dist/config/engine.d.ts +21 -1
  9. package/dist/config/engine.js +22 -1
  10. package/dist/config/nodeVersion.d.ts +13 -0
  11. package/dist/config/nodeVersion.js +26 -0
  12. package/dist/index.js +22 -7
  13. package/dist/mcp/client.d.ts +28 -0
  14. package/dist/mcp/client.js +62 -8
  15. package/dist/mcp/ide.d.ts +76 -0
  16. package/dist/mcp/ide.js +160 -0
  17. package/dist/mcp/manager.js +9 -0
  18. package/dist/tools/browserExtension.js +153 -7
  19. package/dist/tools/computerUse.d.ts +1 -1
  20. package/dist/tools/computerUse.js +183 -2
  21. package/dist/tools/definitions.js +9 -2
  22. package/dist/tools/grepSearch.js +111 -23
  23. package/dist/tools/runner.js +1 -1
  24. package/dist/tools/toolDefsComputer.js +6 -2
  25. package/dist/tools/visionBridge.js +60 -3
  26. package/dist/tools/win/hostScript.js +154 -0
  27. package/dist/tools/writeFile.d.ts +16 -1
  28. package/dist/tools/writeFile.js +22 -1
  29. package/dist/ui/interrupt.d.ts +10 -0
  30. package/dist/ui/interrupt.js +53 -4
  31. package/dist/ui/keyboardGuard.d.ts +21 -0
  32. package/dist/ui/keyboardGuard.js +30 -1
  33. package/dist/ui/pegados.d.ts +50 -0
  34. package/dist/ui/pegados.js +83 -0
  35. package/dist/ui/pieFijo.d.ts +56 -0
  36. package/dist/ui/pieFijo.js +309 -0
  37. package/dist/ui/prompt.d.ts +5 -0
  38. package/dist/ui/prompt.js +109 -16
  39. package/dist/ui/reasoningStream.js +22 -12
  40. package/dist/ui/salida.d.ts +10 -0
  41. package/dist/ui/salida.js +50 -0
  42. package/dist/ui/spinner.d.ts +15 -8
  43. package/dist/ui/spinner.js +80 -42
  44. package/extension/background.js +191 -14
  45. package/extension/content.js +446 -48
  46. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  47. package/extension/iconos/128.png +0 -0
  48. package/extension/iconos/16.png +0 -0
  49. package/extension/iconos/32.png +0 -0
  50. package/extension/iconos/48.png +0 -0
  51. package/extension/iconos/ORIGEN.md +48 -0
  52. package/extension/iconos/logotipo.png +0 -0
  53. package/extension/manifest.json +27 -4
  54. package/extension/popup.html +16 -6
  55. package/package.json +3 -2
@@ -5,9 +5,10 @@ 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, isContextOverflow } from "./retry.js";
8
+ import { withRetry, isContextOverflow, esDemasiadasEnCurso } from "./retry.js";
9
9
  import { canRunInParallel } from "../tools/safety.js";
10
10
  import { createEngineClient, explicarErrorDelMotor, motivoDeParadaDelError, } from "../config/engine.js";
11
+ import { anotarHuecosOcupados, liberarHuecos, mensajeDeEspera, segundosDeEspera } from "./huecos.js";
11
12
  import { MarkdownStream } from "../ui/markdownStream.js";
12
13
  import { checkQuota, recordUsage, mensajeDeCorte, quotaBadge } from "../config/quota.js";
13
14
  import { ReasoningStream } from "../ui/reasoningStream.js";
@@ -15,6 +16,7 @@ import { PermissionManager, commandSignature, isDangerousCommand } from "../conf
15
16
  import { askToolPermission } from "../ui/permissionPrompt.js";
16
17
  import { DynamicSpinner } from "../ui/spinner.js";
17
18
  import { globalUndoManager } from "./undoManager.js";
19
+ import { salida } from "./salidaDelAgente.js";
18
20
  import { InterruptWatcher, isAbortError } from "../ui/interrupt.js";
19
21
  import { HookManager } from "../hooks/manager.js";
20
22
  import { registerToolGate, autorizarHerramienta, notificarPostHerramienta } from "./toolGate.js";
@@ -33,6 +35,15 @@ const HERRAMIENTAS_QUE_ESCRIBEN = new Set([
33
35
  ]);
34
36
  /** Cortes por longitud seguidos que se toleran antes de rendirse. */
35
37
  const MAX_CORTES_POR_LONGITUD = 2;
38
+ /**
39
+ * Respuestas vacias seguidas que se toleran antes de rendirse.
40
+ *
41
+ * Dos, no una: una respuesta vacia suelta es casi siempre un hipo del motor y
42
+ * el reintento la resuelve sin que el usuario se entere. Tres seguidas ya no es
43
+ * un hipo, y seguir reintentando en silencio es lo que hacia que pareciera que
44
+ * el agente te ignora.
45
+ */
46
+ const MAX_RESPUESTAS_VACIAS = 1;
36
47
  /** Mensajes recientes que la compactacion no resume nunca. */
37
48
  const COLA_INTACTA = 4;
38
49
  /**
@@ -66,8 +77,43 @@ function parsearArgumentos(bruto) {
66
77
  /** Lo que se le dice al modelo cuando sus argumentos llegaron a medias. */
67
78
  function mensajeDeArgumentosRotos(herramienta) {
68
79
  return (`No se ejecuto "${herramienta}": los argumentos llegaron incompletos, seguramente porque la ` +
69
- `respuesta se corto por longitud. No se ha hecho nada. Vuelve a pedirlo, y si el contenido es ` +
70
- `largo, escribelo en varias llamadas mas pequenas en vez de una sola.`);
80
+ `respuesta se corto por longitud. No se ha hecho nada.
81
+
82
+ ` +
83
+ `Si era un archivo largo, hazlo por partes: una primera llamada a write_file con el principio ` +
84
+ `y las siguientes con modo="anadir" para ir pegando el resto. Cada trozo, por debajo de unas ` +
85
+ `400 lineas.`);
86
+ }
87
+ /**
88
+ * Deja una llamada a herramienta en un estado que la API acepte.
89
+ *
90
+ * EL FALLO QUE CIERRA ESTO
91
+ *
92
+ * Cuando la respuesta se corta a mitad de un `write_file` con un HTML largo
93
+ * dentro, los argumentos llegan asi:
94
+ *
95
+ * {"path":"index.html","content":"<!doctype html><html><head><style>body{ma
96
+ *
97
+ * La herramienta ya no se ejecutaba -eso se arreglo antes-, pero la llamada se
98
+ * guardaba en el historial TAL CUAL, y el historial se reenvia entero en cada
99
+ * peticion. La API valida que los argumentos sean JSON, no lo son, y contesta
100
+ * 400. Desde ese momento la sesion queda inservible: todo lo que escriba el
101
+ * usuario falla igual, la unica salida es /clear, y nadie lo sabe.
102
+ *
103
+ * Se vio en una sesion real, y encima el 400 se llevo por delante los cuatro
104
+ * huecos de peticion de la licencia, asi que al fallo se le sumo un "Tienes 4
105
+ * peticiones en curso" de dos minutos y medio.
106
+ *
107
+ * Guardando "{}" el historial sigue siendo valido y la pareja llamada/respuesta
108
+ * cuadra. Al modelo no se le oculta nada: el resultado de esa herramienta ya le
109
+ * dice que sus argumentos llegaron a medias y que no se hizo nada.
110
+ */
111
+ function sanearLlamada(tc) {
112
+ const bruto = tc?.function?.arguments;
113
+ const texto = typeof bruto === "string" ? bruto.trim() : "";
114
+ if (texto && parsearArgumentos(texto).ok)
115
+ return tc;
116
+ return { ...tc, function: { ...tc.function, arguments: "{}" } };
71
117
  }
72
118
  export class AgentLoop {
73
119
  client;
@@ -223,7 +269,7 @@ export class AgentLoop {
223
269
  const proyectado = antes > 0 ? Math.round(tokensActuales * (despues / antes)) : tokensActuales;
224
270
  this.lastTurnTokens = proyectado;
225
271
  if (proyectado < COMPACT_THRESHOLD_TOKENS * 0.8) {
226
- console.log(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
272
+ salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
227
273
  chalk.gray(`Liberados ~${(antes - despues).toLocaleString()} tokens de salidas de herramientas antiguas. ` +
228
274
  "La conversacion se conserva entera.\n"));
229
275
  return true;
@@ -278,7 +324,7 @@ export class AgentLoop {
278
324
  // Queda una estimacion hasta la siguiente respuesta de la API: mejor eso
279
325
  // que arrastrar la cifra vieja, que dispararia otra compactacion seguida.
280
326
  this.lastTurnTokens = estimarTokens(this.messages);
281
- console.log(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
327
+ salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
282
328
  chalk.gray(`Contexto optimizado automáticamente (${this.messages.length} mensajes limpios) para mantener alta velocidad y mínimo consumo de API.\n`));
283
329
  return true;
284
330
  }
@@ -286,7 +332,7 @@ export class AgentLoop {
286
332
  spinner.stop();
287
333
  // El catch mudo era medio fallo: si el resumen no sale, el turno sigue con
288
334
  // el contexto entero y el error de despues llega sin explicacion.
289
- console.log(chalk.gray(` (no se pudo compactar el contexto: ${err?.message || String(err)})`));
335
+ salida.linea(chalk.gray(` (no se pudo compactar el contexto: ${err?.message || String(err)})`));
290
336
  return false;
291
337
  }
292
338
  }
@@ -307,7 +353,7 @@ export class AgentLoop {
307
353
  if (!cmd)
308
354
  return null;
309
355
  if (reflexiones >= MAX_REFLEXIONES) {
310
- console.log(chalk.yellow(`\n⚠ ${mensajeDeRendicion(cmd.comando)}\n`));
356
+ salida.linea(chalk.yellow(`\n⚠ ${mensajeDeRendicion(cmd.comando)}\n`));
311
357
  return null;
312
358
  }
313
359
  const spinner = new DynamicSpinner();
@@ -320,13 +366,13 @@ export class AgentLoop {
320
366
  spinner.stop();
321
367
  }
322
368
  if (resultado.ok) {
323
- console.log(chalk.gray(` ✔ Verificado con ${cmd.comando}\n`));
369
+ salida.linea(chalk.gray(` ✔ Verificado con ${cmd.comando}\n`));
324
370
  // Lo verificado deja de estar pendiente: si el modelo vuelve a editar,
325
371
  // se vuelve a comprobar; si no, no se comprueba dos veces lo mismo.
326
372
  this.archivosTocados.clear();
327
373
  return null;
328
374
  }
329
- console.log(chalk.yellow(`\n⚠ La verificacion fallo (${cmd.comando}). Se lo devuelvo al agente.\n`));
375
+ salida.linea(chalk.yellow(`\n⚠ La verificacion fallo (${cmd.comando}). Se lo devuelvo al agente.\n`));
330
376
  return mensajeDeVerificacion(cmd.comando, resultado.salida, reflexiones + 1);
331
377
  }
332
378
  /**
@@ -410,12 +456,21 @@ export class AgentLoop {
410
456
  tokensSalida: turnCompletionTokens,
411
457
  };
412
458
  };
459
+ // Los huecos de peticion, ANTES de gastar uno. El proxy ya nos dijo cuanto
460
+ // falta; volver a preguntarselo no puede salir bien por definicion, y esa
461
+ // llamada es justo la que puede refrescar la reserva y alargar la espera.
462
+ const espera = segundosDeEspera();
463
+ if (espera > 0) {
464
+ const aviso = mensajeDeEspera(espera);
465
+ salida.linea(chalk.yellow(`\n⏸ ${aviso}\n`));
466
+ return parada("ocupado", aviso);
467
+ }
413
468
  // Cuota ANTES de gastar. Si no queda margen, se dice y no se llama al motor:
414
469
  // avisar despues de haber gastado no sirve de nada.
415
470
  const cuota = checkQuota();
416
471
  if (!cuota.allowed) {
417
472
  const corte = mensajeDeCorte(cuota);
418
- console.log(chalk.yellow(`\n⏸ ${corte}`) +
473
+ salida.linea(chalk.yellow(`\n⏸ ${corte}`) +
419
474
  chalk.gray("\n Tu trabajo y tu contexto siguen aqui: al liberarse, continuas donde lo dejaste.\n"));
420
475
  return parada("cuota", corte);
421
476
  }
@@ -438,6 +493,8 @@ export class AgentLoop {
438
493
  let reflexiones = 0;
439
494
  /** Veces seguidas que la respuesta se corto por longitud sin hacer nada. */
440
495
  let cortesPorLongitud = 0;
496
+ /** Veces seguidas que el turno acabo sin una sola letra que ensenar. */
497
+ let respuestasVacias = 0;
441
498
  // Se limpia aqui: lo que se edito en el turno anterior ya se verifico (o no
442
499
  // se iba a verificar nunca).
443
500
  this.archivosTocados.clear();
@@ -479,7 +536,7 @@ export class AgentLoop {
479
536
  }, { signal }), {
480
537
  onRetry: (attempt, delay, reason) => {
481
538
  spinner.stop();
482
- console.log(chalk.yellow(`\n⟳ El motor respondio ${reason}. Reintento ${attempt} en ${(delay / 1000).toFixed(1)}s...`));
539
+ salida.linea(chalk.yellow(`\n⟳ El motor respondio ${reason}. Reintento ${attempt} en ${(delay / 1000).toFixed(1)}s...`));
483
540
  spinner.start();
484
541
  },
485
542
  });
@@ -488,7 +545,7 @@ export class AgentLoop {
488
545
  spinner.stop();
489
546
  watcher.stop();
490
547
  if (isAbortError(err) || watcher.interrupted) {
491
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
548
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
492
549
  return parada("interrumpido");
493
550
  }
494
551
  // El contexto no cabe. Reintentar la misma peticion es imposible por
@@ -497,7 +554,7 @@ export class AgentLoop {
497
554
  // sola vez: si tras compactar sigue sin caber, el problema es otro.
498
555
  if (isContextOverflow(err) && !yaCompactadoPorDesbordamiento) {
499
556
  yaCompactadoPorDesbordamiento = true;
500
- console.log(chalk.yellow("\n⚠ La peticion no cabia en el contexto. Compactando y reintentando..."));
557
+ salida.linea(chalk.yellow("\n⚠ La peticion no cabia en el contexto. Compactando y reintentando..."));
501
558
  const activo = this.messages[0]?.role === "system" ? this.messages[0].content : "";
502
559
  await this.compactHistory(activo);
503
560
  iterations--; // este intento no llego al modelo: no cuenta como vuelta
@@ -506,9 +563,14 @@ export class AgentLoop {
506
563
  // Un 402 (cuota agotada) o un 401 (licencia caida) no son "fallos del
507
564
  // motor" y ademas no se reintentan nunca: decirlo como tal confunde y
508
565
  // hace que el usuario piense que el producto esta roto.
566
+ // Si el 429 es de huecos ocupados, se apunta hasta cuando: asi el
567
+ // siguiente mensaje del usuario se responde desde aqui en vez de gastar
568
+ // otra peticion para que le repitan la misma pared.
569
+ if (esDemasiadasEnCurso(err))
570
+ anotarHuecosOcupados(err?.error?.retry_after_s);
509
571
  const claro = explicarErrorDelMotor(err);
510
572
  if (claro) {
511
- console.log(chalk.yellow(`
573
+ salida.linea(chalk.yellow(`
512
574
  ⏸ ${claro}
513
575
  `));
514
576
  return parada(motivoDeParadaDelError(err), claro);
@@ -517,19 +579,34 @@ export class AgentLoop {
517
579
  renderError(fallo);
518
580
  return parada("error-motor", fallo);
519
581
  }
582
+ // El motor contesto: habia hueco. Seguir bloqueando por una espera vieja
583
+ // seria mentirle al usuario y negarle el servicio que si tiene.
584
+ liberarHuecos();
520
585
  let content = "";
521
586
  let reasoning = "";
522
587
  let motivoDeCorte = null;
588
+ /**
589
+ * Si llego la MARCA de final del stream.
590
+ *
591
+ * Un stream sano termina diciendo por que termina: un `finish_reason` y,
592
+ * con `include_usage`, un ultimo trozo con el gasto. Cuando el cuerpo se
593
+ * corta a mitad no llega ninguno de los dos y el iterador se acaba sin
594
+ * error, exactamente igual que si hubiera terminado bien. Hacen falta las
595
+ * dos ausencias: fiarse de una sola convertiria en "cortada" cualquier
596
+ * respuesta de un motor que no mande esa marca.
597
+ */
598
+ let llegoElFinal = false;
523
599
  const rawToolCalls = [];
524
- let isFirstChunk = true;
525
600
  const reasoningStream = new ReasoningStream();
526
- const markdown = new MarkdownStream();
601
+ // Por `salida`, no por stdout: mientras se escribe la respuesta el spinner
602
+ // sigue puesto abajo, y escribir directo lo pisaria.
603
+ const markdown = new MarkdownStream((s) => salida.crudo(s));
527
604
  try {
528
605
  for await (const chunk of stream) {
529
- if (isFirstChunk) {
530
- spinner.stop();
531
- isFirstChunk = false;
532
- }
606
+ // El spinner ya NO se para al llegar el primer trozo. Vive en el pie
607
+ // fijo, asi que la respuesta sale por encima sin taparlo y el contador
608
+ // de segundos sigue a la vista mientras el modelo habla, que es
609
+ // exactamente cuando uno quiere saber cuanto lleva.
533
610
  const delta = chunk.choices?.[0]?.delta;
534
611
  // Handle real-time reasoning stream
535
612
  if (delta?.reasoning_content) {
@@ -543,6 +620,12 @@ export class AgentLoop {
543
620
  content += delta.content;
544
621
  markdown.push(delta.content);
545
622
  }
623
+ // El contador del spinner, en vivo. El `usage` de verdad solo llega
624
+ // en el ultimo trozo: sin esta estimacion el numero se queda a cero
625
+ // durante toda la respuesta y aparece de golpe justo al acabar, que
626
+ // es cuando ya no le sirve a nadie.
627
+ spinner.tokenCount =
628
+ turnCompletionTokens + Math.round((content.length + reasoning.length) / 4);
546
629
  // Accumulate fragmented tool calls by index tc.index
547
630
  if (delta?.tool_calls) {
548
631
  for (const tc of delta.tool_calls) {
@@ -565,10 +648,13 @@ export class AgentLoop {
565
648
  // El motivo por el que el modelo dejo de hablar. Sin esto, quedarse
566
649
  // sin presupuesto de salida es indistinguible de terminar el turno.
567
650
  const corte = chunk.choices?.[0]?.finish_reason;
568
- if (corte)
651
+ if (corte) {
569
652
  motivoDeCorte = corte;
653
+ llegoElFinal = true;
654
+ }
570
655
  // Track streaming usage metrics
571
656
  if (chunk.usage) {
657
+ llegoElFinal = true;
572
658
  this.tracker.addUsage(chunk.usage);
573
659
  turnPromptTokens += chunk.usage.prompt_tokens || 0;
574
660
  if (chunk.usage.prompt_tokens)
@@ -589,12 +675,14 @@ export class AgentLoop {
589
675
  if (content.trim()) {
590
676
  this.messages.push({ role: "assistant", content });
591
677
  }
592
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
678
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
593
679
  return parada("interrumpido");
594
680
  }
681
+ if (esDemasiadasEnCurso(streamErr))
682
+ anotarHuecosOcupados(streamErr?.error?.retry_after_s);
595
683
  const claroStream = explicarErrorDelMotor(streamErr);
596
684
  if (claroStream) {
597
- console.log(chalk.yellow(`
685
+ salida.linea(chalk.yellow(`
598
686
  ⏸ ${claroStream}
599
687
  `));
600
688
  return parada(motivoDeParadaDelError(streamErr), claroStream);
@@ -610,11 +698,54 @@ export class AgentLoop {
610
698
  watcher.stop();
611
699
  if (content.trim())
612
700
  this.messages.push({ role: "assistant", content });
613
- console.log(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
701
+ salida.linea(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
614
702
  return parada("interrumpido");
615
703
  }
616
704
  // Filter valid accumulated tool calls
617
705
  const toolCalls = rawToolCalls.filter((tc) => tc && tc.function && tc.function.name);
706
+ // LA RESPUESTA SE CORTO A MITAD Y EL STREAM ACABO COMO SI NADA
707
+ //
708
+ // Sesion real. El usuario pregunta por su consumo y ve esto:
709
+ //
710
+ // ¿De cuál consumo me hab
711
+ // ⚡ 12.4s · 0 tokens salida · 100% contexto libre
712
+ //
713
+ // Media palabra y el pie del turno, sin un solo aviso. El cuerpo de la
714
+ // respuesta se cerro por el camino -no el modelo: la conexion-, asi que no
715
+ // llego ni el finish_reason ni el trozo del gasto, y el iterador termino
716
+ // sin lanzar nada. El bucle no distingue "el modelo termino" de "no vino
717
+ // mas", asi que dio el turno por bueno.
718
+ //
719
+ // Y lo peor no se veia: esa media frase se guardaba en el historial como
720
+ // respuesta completa del asistente. El turno siguiente salia con un
721
+ // historial que acaba a mitad de palabra, el modelo contestaba vacio, y el
722
+ // reintento de las respuestas vacias gastaba otro hueco de peticion.
723
+ // Cuatro mensajes despues: "Tienes 4 peticiones en curso. Reintenta en
724
+ // 197s", y el usuario sin servicio durante tres minutos.
725
+ //
726
+ // NO SE REINTENTA, A PROPOSITO. Cada intento ocupa uno de los cuatro
727
+ // huecos de la licencia durante minutos, y los cortes vienen en rachas:
728
+ // reintentar es justo como se pasa de perder un hueco a perder los cuatro.
729
+ // Mas vale que el usuario reescriba una linea.
730
+ if (!llegoElFinal && (content.trim().length > 0 || toolCalls.length > 0)) {
731
+ watcher.stop();
732
+ // Lo que llego se guarda, pero MARCADO. Sin la marca, en el turno
733
+ // siguiente el modelo lee su propia frase a medias como algo que decidio
734
+ // dejar asi, y responde a un texto que nadie escribio.
735
+ if (content.trim()) {
736
+ this.messages.push({
737
+ role: "assistant",
738
+ content: `${content}\n\n[Aqui se corto: la conexion con el motor se cerro a mitad de la respuesta.]`,
739
+ });
740
+ }
741
+ // Las llamadas a herramienta de un stream cortado NO se ejecutan: sus
742
+ // argumentos estan tan a medias como el texto.
743
+ const aviso = "La respuesta se corto a mitad: la conexion con el motor se cerro sin avisar. " +
744
+ "Lo que llego se queda arriba. No lo reintento solo porque cada intento ocupa uno de " +
745
+ "los cuatro huecos de tu licencia durante varios minutos; vuelve a pedirmelo y sigo.";
746
+ salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
747
+ return parada("error-motor", aviso);
748
+ }
618
749
  // El modelo se quedo sin presupuesto de salida y no llego a pedir nada.
619
750
  //
620
751
  // Esto se veia EXACTAMENTE igual que un turno terminado: sin llamadas a
@@ -630,19 +761,20 @@ export class AgentLoop {
630
761
  if (cortesPorLongitud > MAX_CORTES_POR_LONGITUD) {
631
762
  const aviso = `La respuesta se corto por longitud ${cortesPorLongitud} veces seguidas sin llegar a ` +
632
763
  `hacer nada. Prueba a pedirlo por partes.`;
633
- console.log(chalk.yellow(`\n ⚠ ${aviso}\n`));
764
+ salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
634
765
  if (content.trim())
635
766
  this.messages.push({ role: "assistant", content });
636
767
  return parada("limite-iteraciones", aviso);
637
768
  }
638
- console.log(chalk.yellow("\n ⚠ La respuesta se corto por longitud antes de hacer nada. Pidiendole que vaya al grano...\n"));
769
+ salida.linea(chalk.yellow("\n ⚠ La respuesta se corto por longitud antes de hacer nada. Pidiendole que vaya al grano...\n"));
639
770
  if (content.trim())
640
771
  this.messages.push({ role: "assistant", content });
641
772
  this.messages.push({
642
773
  role: "user",
643
774
  content: "Tu respuesta anterior se corto porque agoto el presupuesto de salida mientras razonabas, " +
644
775
  "y no llegaste a usar ninguna herramienta: no se ha hecho nada todavia. Deja de planificar " +
645
- "y actua ya. Si lo que hay que escribir es largo, hazlo en varias llamadas mas pequenas.",
776
+ "y actua ya. Si lo que hay que escribir es largo, empieza con write_file y sigue con " +
777
+ 'write_file en modo="anadir", en trozos de menos de 400 lineas.',
646
778
  });
647
779
  // Sin esto se queda un vigilante de teclado vivo por cada corte, y cada
648
780
  // uno vuelve a apuntarse como oyente: la misma tecla se procesa tantas
@@ -655,7 +787,10 @@ export class AgentLoop {
655
787
  this.messages.push({
656
788
  role: "assistant",
657
789
  content: content || null,
658
- tool_calls: toolCalls,
790
+ // Sanear NO es cosmetico: unos argumentos cortados guardados tal cual
791
+ // hacen que la API conteste 400 en TODAS las peticiones siguientes, y
792
+ // la sesion queda inservible hasta un /clear. Ver sanearLlamada().
793
+ tool_calls: toolCalls.map(sanearLlamada),
659
794
  });
660
795
  const toolResults = [];
661
796
  // Las llamadas independientes de solo lectura se lanzan a la vez. Cuando
@@ -733,7 +868,7 @@ export class AgentLoop {
733
868
  toolResults.forEach((r) => this.messages.push(r));
734
869
  watcher.stop();
735
870
  if (watcher.interrupted) {
736
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
871
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
737
872
  return parada("interrumpido");
738
873
  }
739
874
  continue;
@@ -781,7 +916,7 @@ export class AgentLoop {
781
916
  fnArgs = { ...fnArgs, ...pre.updatedInput };
782
917
  }
783
918
  if (pre.systemMessage) {
784
- console.log(chalk.yellow(`\n⚑ ${pre.systemMessage}`));
919
+ salida.linea(chalk.yellow(`\n⚑ ${pre.systemMessage}`));
785
920
  }
786
921
  // Modo plan: bloqueo duro, no un dialogo. Lo que el modo promete es
787
922
  // solo lectura, asi que aqui no hay nada que preguntar.
@@ -848,7 +983,7 @@ export class AgentLoop {
848
983
  // El systemMessage de PreToolUse si se imprimia y el de PostToolUse
849
984
  // no: el mismo campo, del mismo hook, se veia o no segun el evento.
850
985
  if (post.systemMessage) {
851
- console.log(chalk.yellow(`\n⚑ ${post.systemMessage}`));
986
+ salida.linea(chalk.yellow(`\n⚑ ${post.systemMessage}`));
852
987
  }
853
988
  // El dialogo del bucle usa stdin, igual que el de permisos: hay que
854
989
  // soltarle el terminal al vigilante de Esc. Pero solo si de verdad se
@@ -867,20 +1002,62 @@ export class AgentLoop {
867
1002
  toolResults.forEach((r) => this.messages.push(r));
868
1003
  watcher.stop();
869
1004
  if (watcher.interrupted) {
870
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
1005
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
871
1006
  return parada("interrumpido");
872
1007
  }
873
1008
  continue;
874
1009
  }
875
1010
  watcher.stop();
876
- // If text content was printed, ensure newline and record assistant message
877
- if (content.trim()) {
878
- process.stdout.write("\n\n");
1011
+ // EL TURNO ACABA SIN UNA SOLA LETRA QUE ENSENAR
1012
+ //
1013
+ // Salio de un caso real: cuatro ordenes seguidas ("puedes conectarte a mi
1014
+ // vscode?", "si?", "puedes?", "a mi vscode") contestadas todas con esto:
1015
+ //
1016
+ // 💭 penso 1s
1017
+ // ⚡ 4.8s · 0 tokens salida · $0.00000 USD · 100% contexto libre
1018
+ //
1019
+ // Ni un caracter en pantalla, ni un error, ni un aviso. Desde fuera es
1020
+ // identico a que el agente te ignore, y el usuario reescribe la orden
1021
+ // pensando que no llego. Encima cada una de esas cuatro se llevo por
1022
+ // delante un hueco de peticion en el proxy, asi que la quinta choco con
1023
+ // "Tienes 4 peticiones en curso": el silencio no solo confunde, ademas
1024
+ // deja al usuario sin servicio.
1025
+ //
1026
+ // Es el mismo agujero que el corte por longitud, en su otra cara: el
1027
+ // codigo no distingue "el modelo termino" de "no vino nada". Sin
1028
+ // herramientas y sin texto, el bucle daba el turno por hecho, imprimia el
1029
+ // pie y volvia al prompt.
1030
+ if (!content.trim()) {
1031
+ respuestasVacias++;
1032
+ // Se reintenta UNA vez, no mas, y aqui la moderacion importa: cada
1033
+ // intento consume un hueco de peticion de la licencia, y solo hay
1034
+ // cuatro. Insistir con un motor que no contesta es la forma mas rapida
1035
+ // de quedarse sin ninguno.
1036
+ if (respuestasVacias > MAX_RESPUESTAS_VACIAS) {
1037
+ const aviso = `El motor devolvio ${respuestasVacias} respuestas vacias seguidas. ` +
1038
+ "No es tu conexion ni tu orden: no esta llegando nada del modelo. " +
1039
+ "Espera un momento y reintenta, o /clear si venias de una conversacion larga.";
1040
+ salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
1041
+ return parada("error-motor", aviso);
1042
+ }
1043
+ salida.linea(chalk.gray("\n ⚠ El motor no devolvio nada. Reintentando una vez…\n"));
879
1044
  this.messages.push({
880
- role: "assistant",
881
- content,
1045
+ role: "user",
1046
+ content: "Tu respuesta anterior llego vacia: ni texto ni llamadas a herramienta. " +
1047
+ "El usuario no ha visto absolutamente nada. Contesta a lo que te pidio, " +
1048
+ "aunque sea para decir que no puedes hacerlo o que no lo has entendido.",
882
1049
  });
1050
+ continue;
883
1051
  }
1052
+ // Una respuesta con contenido cierra la racha: lo que cuenta son las
1053
+ // vacias SEGUIDAS, no las de toda la sesion.
1054
+ respuestasVacias = 0;
1055
+ // If text content was printed, ensure newline and record assistant message
1056
+ salida.crudo("\n\n");
1057
+ this.messages.push({
1058
+ role: "assistant",
1059
+ content,
1060
+ });
884
1061
  // El modelo da el turno por cerrado. Si toco archivos y el proyecto tiene
885
1062
  // verificacion automatica activada, se comprueba ANTES de creerselo: un
886
1063
  // error de tipos que nadie mira se queda en el disco y los turnos
@@ -914,19 +1091,19 @@ export class AgentLoop {
914
1091
  const tras = checkQuota();
915
1092
  renderFooter(formattedCost, turnCompletionTokens, turnCachedTokens, this.currentEspectro.name, durationMs, contextPercent, quotaBadge(tras));
916
1093
  if (auxiliar.total > 0) {
917
- console.log(chalk.gray(` + $${auxiliar.total.toFixed(4)} USD fuera del bucle (${auxiliar.rutas}), ya contados en la cuota.\n`));
1094
+ salida.linea(chalk.gray(` + $${auxiliar.total.toFixed(4)} USD fuera del bucle (${auxiliar.rutas}), ya contados en la cuota.\n`));
918
1095
  }
919
1096
  // Aviso al acercarse, no solo al chocar: quedarse a medias sin previo aviso
920
1097
  // es lo que hace que la gente cancele.
921
1098
  if (tras.allowed && tras.spentWeekUsd >= tras.config.weeklyBudgetUsd * 0.8) {
922
1099
  const queda = Math.max(0, tras.config.weeklyBudgetUsd - tras.spentWeekUsd);
923
- console.log(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
1100
+ salida.linea(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
924
1101
  }
925
1102
  if (!completado) {
926
1103
  // Se agotaron las iteraciones con trabajo a medias. En interactivo se ve
927
1104
  // porque el ultimo mensaje queda cortado; en CI no lo ve nadie.
928
1105
  const aviso = `El turno se agoto en el limite de ${this.maxIterations} iteraciones sin cerrar la tarea.`;
929
- console.log(chalk.yellow(` ⚠ ${aviso}\n`));
1106
+ salida.linea(chalk.yellow(` ⚠ ${aviso}\n`));
930
1107
  return parada("limite-iteraciones", aviso);
931
1108
  }
932
1109
  return parada("completado");
@@ -948,7 +1125,7 @@ export class AgentLoop {
948
1125
  const command = typeof fnArgs?.command === "string" ? fnArgs.command : "";
949
1126
  if (command) {
950
1127
  if (isDangerousCommand(command)) {
951
- console.log(chalk.gray(" (este comando es destructivo: se seguira preguntando cada vez, no se puede recordar)\n"));
1128
+ salida.linea(chalk.gray(" (este comando es destructivo: se seguira preguntando cada vez, no se puede recordar)\n"));
952
1129
  return;
953
1130
  }
954
1131
  const firma = commandSignature(command);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Por donde habla el bucle del agente.
3
+ *
4
+ * POR QUE
5
+ *
6
+ * El bucle escribia con `console.log` en diecisiete sitios. Mientras el unico
7
+ * sitio donde se ve al agente sea una terminal eso no molesta, pero ata el
8
+ * motor a su pantalla: para poner el agente en un panel de VSCode, en una web o
9
+ * en una app, hay que arrancarle esos diecisiete `console.log` uno a uno, y
10
+ * cada uno es una ocasion de romper algo.
11
+ *
12
+ * Con esto en medio, el bucle deja de saber donde acaba lo que dice. La
13
+ * terminal pasa a ser UNA implementacion, no LA implementacion.
14
+ *
15
+ * POR QUE RECIBE EL TEXTO YA COLOREADO
16
+ *
17
+ * Es a proposito, y es lo que hace que este cambio no pueda romper nada: lo que
18
+ * el bucle manda es exactamente la misma cadena que antes le pasaba a
19
+ * `console.log`, colores incluidos. La salida por terminal queda byte a byte
20
+ * igual que ayer, y las 538 pruebas siguen valiendo como red.
21
+ *
22
+ * Un panel que quiera pintarlo a su manera quita los codigos de color con
23
+ * `sinColores()` y se queda con el texto. Cuando haga falta distinguir un aviso
24
+ * de una nota, se anaden metodos con significado; hoy no hace falta y adivinarlo
25
+ * seria inventar trabajo.
26
+ */
27
+ export interface SalidaDelAgente {
28
+ /** Un mensaje suelto, con su salto de linea al final. Equivale a console.log. */
29
+ linea(texto: string): void;
30
+ /** Escritura sin salto, para separar bloques. Equivale a process.stdout.write. */
31
+ crudo(texto: string): void;
32
+ }
33
+ /** Lo que usa el bucle. No sabe ni le importa quien esta al otro lado. */
34
+ export declare const salida: SalidaDelAgente;
35
+ /**
36
+ * Cambia a donde habla el agente. Con `null` vuelve a la terminal.
37
+ *
38
+ * Lo usara el panel de VSCode. Devolver siempre a la terminal al terminar: un
39
+ * agente que habla a un panel cerrado no habla con nadie.
40
+ */
41
+ export declare function dirigirSalidaDelAgente(destino: SalidaDelAgente | null): void;
42
+ /** Quita los codigos de color, para quien pinte el texto a su manera. */
43
+ export declare function sinColores(texto: string): string;
@@ -0,0 +1,39 @@
1
+ import { escribirEncimaDelPie } from "../ui/pieFijo.js";
2
+ /**
3
+ * Pasa por `pieFijo` y no por `console.log` a secas.
4
+ *
5
+ * Mientras el spinner esta puesto hay un bloque pintado abajo del todo. Escribir
6
+ * directo lo pisaria: la respuesta saldria encima de la linea del spinner y el
7
+ * repintado siguiente ya no sabria donde empieza su bloque. Pasando por aqui, el
8
+ * pie se aparta, la linea entra en el historial como cualquier otra, y el pie
9
+ * vuelve a pintarse debajo.
10
+ *
11
+ * Sin pie puesto -que es casi todo el rato- esto es un `write` normal.
12
+ */
13
+ const A_LA_TERMINAL = {
14
+ linea: (texto) => {
15
+ escribirEncimaDelPie(`${texto}\n`);
16
+ },
17
+ crudo: (texto) => {
18
+ escribirEncimaDelPie(texto);
19
+ },
20
+ };
21
+ let actual = A_LA_TERMINAL;
22
+ /** Lo que usa el bucle. No sabe ni le importa quien esta al otro lado. */
23
+ export const salida = {
24
+ linea: (texto) => actual.linea(texto),
25
+ crudo: (texto) => actual.crudo(texto),
26
+ };
27
+ /**
28
+ * Cambia a donde habla el agente. Con `null` vuelve a la terminal.
29
+ *
30
+ * Lo usara el panel de VSCode. Devolver siempre a la terminal al terminar: un
31
+ * agente que habla a un panel cerrado no habla con nadie.
32
+ */
33
+ export function dirigirSalidaDelAgente(destino) {
34
+ actual = destino ?? A_LA_TERMINAL;
35
+ }
36
+ /** Quita los codigos de color, para quien pinte el texto a su manera. */
37
+ export function sinColores(texto) {
38
+ return String(texto).replace(/\x1b\[[0-9;]*m/g, "");
39
+ }
@@ -3,7 +3,7 @@ import chalk from "chalk";
3
3
  import { PermissionManager, commandSignature } from "../config/permissions.js";
4
4
  import { HookManager } from "../hooks/manager.js";
5
5
  import { askToolPermission } from "../ui/permissionPrompt.js";
6
- import { releaseKeyboard } from "../ui/interrupt.js";
6
+ import { releaseKeyboard, retomarTeclado } from "../ui/interrupt.js";
7
7
  let activa = null;
8
8
  /** Lo llama el AgentLoop al construirse. */
9
9
  export function registerToolGate(permissions, hooks) {
@@ -75,6 +75,11 @@ export async function autorizarHerramienta(toolName, args, cwd = process.cwd(),
75
75
  releaseKeyboard();
76
76
  console.log(chalk.gray(`\n${origen} pide permiso para usar "${toolName}":`));
77
77
  const res = await askToolPermission(toolName, finales, cwd);
78
+ // Se devuelve el teclado a quien lo tenia. Sin esto, el resto del turno se
79
+ // queda sin vigilante: Esc deja de cancelar, lo que el usuario escriba no se
80
+ // encola, y el terminal vuelve al modo cocido -donde lo tecleado no aparece
81
+ // hasta pulsar Enter, que es como se veia el "se queda trabado".
82
+ retomarTeclado();
78
83
  if (!res.approved) {
79
84
  return {
80
85
  permitida: false,
@@ -38,7 +38,27 @@ export declare function engineCredential(fallbackApiKey: string): string;
38
38
  * cualquiera apuntando al mismo endpoint.
39
39
  */
40
40
  export declare function engineHeaders(route: EngineRoute): Record<string, string>;
41
- /** Cliente del motor ya configurado. Es el unico sitio donde se construye. */
41
+ /**
42
+ * Cliente del motor ya configurado. Es el unico sitio donde se construye.
43
+ *
44
+ * POR QUE `maxRetries: 0`
45
+ *
46
+ * El SDK reintenta por su cuenta dos veces mas (su valor por defecto), y encima
47
+ * de eso el agente pasa cada llamada por `withRetry`. Dos capas apiladas, y solo
48
+ * una de las dos sabe algo del proxy.
49
+ *
50
+ * Medido contra un servidor de mentira: una sola llamada que recibe un 429 de
51
+ * "tienes 4 peticiones en curso" sale por la red TRES veces antes de que el
52
+ * error llegue siquiera a nuestro codigo. Y nuestro codigo lo tiene bien
53
+ * clasificado -`isRetryable` devuelve false para ese 429 justamente porque cada
54
+ * intento ocupa un hueco-, pero para entonces ya se han gastado los otros dos.
55
+ *
56
+ * Asi es como un solo corte de conexion se comia los cuatro huecos de la
57
+ * licencia y dejaba al usuario tres minutos sin servicio. La politica de
58
+ * reintentos vive en `withRetry` y en un solo sitio: ahi se sabe que un
59
+ * desbordamiento de contexto no puede empezar a caber, y que insistir con los
60
+ * huecos ocupados es la forma mas rapida de quedarse sin ninguno.
61
+ */
42
62
  export declare function createEngineClient(apiKey: string, route: EngineRoute): OpenAI;
43
63
  /**
44
64
  * De que tipo es el fallo, sin mirar el texto ya traducido.