chocolatito-code 1.2.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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * NO GASTAR UN HUECO DE PETICION QUE YA SABEMOS QUE NO HAY
3
+ *
4
+ * DE DONDE SALE
5
+ *
6
+ * Sesion real. Una respuesta se corto a mitad, el bucle reintento, y en cuatro
7
+ * mensajes el usuario se quedo sin servicio:
8
+ *
9
+ * ⏸ Tienes 4 peticiones en curso. Reintenta en 197s.
10
+ * ❯ q paso?
11
+ * ⏸ Tienes 4 peticiones en curso. Reintenta en 189s.
12
+ *
13
+ * Fijate en la segunda. El agente ya SABIA que no habia huecos y que faltaban
14
+ * 197 segundos; ocho segundos despues volvio a llamar al motor para que se lo
15
+ * repitiera. Esa llamada no podia salir bien por definicion, y ademas es la
16
+ * clase de peticion que puede refrescar la reserva del proxy y alargar la
17
+ * espera. El usuario tecleaba, esperaba, y se llevaba la misma pared.
18
+ *
19
+ * LO QUE HACE
20
+ *
21
+ * Se apunta hasta cuando no hay huecos y se responde desde aqui, sin red. Deja
22
+ * de gastarse una peticion por cada intento y el usuario ve la cuenta atras
23
+ * bajar de verdad en lugar de quedarse clavada.
24
+ *
25
+ * POR QUE NO SE FIA DEL RELOJ A CIEGAS
26
+ *
27
+ * Un reloj que se adelanta desbloquea antes de tiempo -inofensivo, la siguiente
28
+ * peticion dira la verdad-. Pero uno que se atrasa dejaria `ocupadoHasta` en un
29
+ * futuro lejano y el agente se negaria a llamar al motor durante horas sin que
30
+ * nada lo despierte. Por eso la espera nunca puede pasar de la ventana que se
31
+ * anoto: en el peor caso se espera de mas una vez, no para siempre.
32
+ */
33
+ /** Cuando el proxy no dice cuanto falta. Un minuto es su reserva mas corta. */
34
+ export declare const SEGUNDOS_POR_DEFECTO = 60;
35
+ /** Techo de la espera. Ninguna reserva del proxy dura mas de unos minutos. */
36
+ export declare const SEGUNDOS_MAXIMOS = 300;
37
+ /**
38
+ * Apunta que los huecos estan llenos.
39
+ *
40
+ * `retryAfterS` es lo que dice el proxy en `error.retry_after_s`. Si no viene,
41
+ * o viene con un valor absurdo, se usa un minuto: adivinar de mas encierra al
42
+ * usuario y adivinar de menos le devuelve a la misma pared.
43
+ */
44
+ export declare function anotarHuecosOcupados(retryAfterS?: unknown): void;
45
+ /** Segundos que faltan para volver a tener hueco. 0 si ya lo hay. */
46
+ export declare function segundosDeEspera(): number;
47
+ /**
48
+ * Olvida la espera. Se llama en cuanto el motor concede una peticion: si
49
+ * contesta, es que habia hueco, y seguir bloqueando seria mentir.
50
+ */
51
+ export declare function liberarHuecos(): void;
52
+ /**
53
+ * Lo que se le dice al usuario en vez de llamar al motor.
54
+ *
55
+ * Dice de donde sale la cuenta -no es una invencion del cliente- y que no tiene
56
+ * que hacer nada: es lo unico que se arregla solo con esperar.
57
+ */
58
+ export declare function mensajeDeEspera(segundos: number): string;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * NO GASTAR UN HUECO DE PETICION QUE YA SABEMOS QUE NO HAY
3
+ *
4
+ * DE DONDE SALE
5
+ *
6
+ * Sesion real. Una respuesta se corto a mitad, el bucle reintento, y en cuatro
7
+ * mensajes el usuario se quedo sin servicio:
8
+ *
9
+ * ⏸ Tienes 4 peticiones en curso. Reintenta en 197s.
10
+ * ❯ q paso?
11
+ * ⏸ Tienes 4 peticiones en curso. Reintenta en 189s.
12
+ *
13
+ * Fijate en la segunda. El agente ya SABIA que no habia huecos y que faltaban
14
+ * 197 segundos; ocho segundos despues volvio a llamar al motor para que se lo
15
+ * repitiera. Esa llamada no podia salir bien por definicion, y ademas es la
16
+ * clase de peticion que puede refrescar la reserva del proxy y alargar la
17
+ * espera. El usuario tecleaba, esperaba, y se llevaba la misma pared.
18
+ *
19
+ * LO QUE HACE
20
+ *
21
+ * Se apunta hasta cuando no hay huecos y se responde desde aqui, sin red. Deja
22
+ * de gastarse una peticion por cada intento y el usuario ve la cuenta atras
23
+ * bajar de verdad en lugar de quedarse clavada.
24
+ *
25
+ * POR QUE NO SE FIA DEL RELOJ A CIEGAS
26
+ *
27
+ * Un reloj que se adelanta desbloquea antes de tiempo -inofensivo, la siguiente
28
+ * peticion dira la verdad-. Pero uno que se atrasa dejaria `ocupadoHasta` en un
29
+ * futuro lejano y el agente se negaria a llamar al motor durante horas sin que
30
+ * nada lo despierte. Por eso la espera nunca puede pasar de la ventana que se
31
+ * anoto: en el peor caso se espera de mas una vez, no para siempre.
32
+ */
33
+ /** Cuando el proxy no dice cuanto falta. Un minuto es su reserva mas corta. */
34
+ export const SEGUNDOS_POR_DEFECTO = 60;
35
+ /** Techo de la espera. Ninguna reserva del proxy dura mas de unos minutos. */
36
+ export const SEGUNDOS_MAXIMOS = 300;
37
+ let ocupadoHasta = 0;
38
+ let ventanaMs = 0;
39
+ /**
40
+ * Apunta que los huecos estan llenos.
41
+ *
42
+ * `retryAfterS` es lo que dice el proxy en `error.retry_after_s`. Si no viene,
43
+ * o viene con un valor absurdo, se usa un minuto: adivinar de mas encierra al
44
+ * usuario y adivinar de menos le devuelve a la misma pared.
45
+ */
46
+ export function anotarHuecosOcupados(retryAfterS) {
47
+ const dicho = Number(retryAfterS);
48
+ const segundos = Number.isFinite(dicho) && dicho > 0 ? Math.min(dicho, SEGUNDOS_MAXIMOS) : SEGUNDOS_POR_DEFECTO;
49
+ ventanaMs = segundos * 1000;
50
+ ocupadoHasta = Date.now() + ventanaMs;
51
+ }
52
+ /** Segundos que faltan para volver a tener hueco. 0 si ya lo hay. */
53
+ export function segundosDeEspera() {
54
+ if (ocupadoHasta === 0)
55
+ return 0;
56
+ const faltan = Math.min(ocupadoHasta - Date.now(), ventanaMs);
57
+ if (faltan <= 0) {
58
+ liberarHuecos();
59
+ return 0;
60
+ }
61
+ return Math.ceil(faltan / 1000);
62
+ }
63
+ /**
64
+ * Olvida la espera. Se llama en cuanto el motor concede una peticion: si
65
+ * contesta, es que habia hueco, y seguir bloqueando seria mentir.
66
+ */
67
+ export function liberarHuecos() {
68
+ ocupadoHasta = 0;
69
+ ventanaMs = 0;
70
+ }
71
+ /**
72
+ * Lo que se le dice al usuario en vez de llamar al motor.
73
+ *
74
+ * Dice de donde sale la cuenta -no es una invencion del cliente- y que no tiene
75
+ * que hacer nada: es lo unico que se arregla solo con esperar.
76
+ */
77
+ export function mensajeDeEspera(segundos) {
78
+ return (`Los huecos de peticion de tu licencia siguen ocupados. Faltan ${segundos}s. ` +
79
+ "No llamo al motor porque la respuesta seria la misma y cada intento puede alargar la espera. " +
80
+ "No hace falta que hagas nada: el hueco se libera solo.");
81
+ }
@@ -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";
@@ -76,8 +77,8 @@ function parsearArgumentos(bruto) {
76
77
  /** Lo que se le dice al modelo cuando sus argumentos llegaron a medias. */
77
78
  function mensajeDeArgumentosRotos(herramienta) {
78
79
  return (`No se ejecuto "${herramienta}": los argumentos llegaron incompletos, seguramente porque la ` +
79
- `respuesta se corto por longitud. No se ha hecho nada.
80
-
80
+ `respuesta se corto por longitud. No se ha hecho nada.
81
+
81
82
  ` +
82
83
  `Si era un archivo largo, hazlo por partes: una primera llamada a write_file con el principio ` +
83
84
  `y las siguientes con modo="anadir" para ir pegando el resto. Cada trozo, por debajo de unas ` +
@@ -455,6 +456,15 @@ export class AgentLoop {
455
456
  tokensSalida: turnCompletionTokens,
456
457
  };
457
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
+ }
458
468
  // Cuota ANTES de gastar. Si no queda margen, se dice y no se llama al motor:
459
469
  // avisar despues de haber gastado no sirve de nada.
460
470
  const cuota = checkQuota();
@@ -553,10 +563,15 @@ export class AgentLoop {
553
563
  // Un 402 (cuota agotada) o un 401 (licencia caida) no son "fallos del
554
564
  // motor" y ademas no se reintentan nunca: decirlo como tal confunde y
555
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);
556
571
  const claro = explicarErrorDelMotor(err);
557
572
  if (claro) {
558
- salida.linea(chalk.yellow(`
559
- ⏸ ${claro}
573
+ salida.linea(chalk.yellow(`
574
+ ⏸ ${claro}
560
575
  `));
561
576
  return parada(motivoDeParadaDelError(err), claro);
562
577
  }
@@ -564,19 +579,34 @@ export class AgentLoop {
564
579
  renderError(fallo);
565
580
  return parada("error-motor", fallo);
566
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();
567
585
  let content = "";
568
586
  let reasoning = "";
569
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;
570
599
  const rawToolCalls = [];
571
- let isFirstChunk = true;
572
600
  const reasoningStream = new ReasoningStream();
573
- 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));
574
604
  try {
575
605
  for await (const chunk of stream) {
576
- if (isFirstChunk) {
577
- spinner.stop();
578
- isFirstChunk = false;
579
- }
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.
580
610
  const delta = chunk.choices?.[0]?.delta;
581
611
  // Handle real-time reasoning stream
582
612
  if (delta?.reasoning_content) {
@@ -590,6 +620,12 @@ export class AgentLoop {
590
620
  content += delta.content;
591
621
  markdown.push(delta.content);
592
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);
593
629
  // Accumulate fragmented tool calls by index tc.index
594
630
  if (delta?.tool_calls) {
595
631
  for (const tc of delta.tool_calls) {
@@ -612,10 +648,13 @@ export class AgentLoop {
612
648
  // El motivo por el que el modelo dejo de hablar. Sin esto, quedarse
613
649
  // sin presupuesto de salida es indistinguible de terminar el turno.
614
650
  const corte = chunk.choices?.[0]?.finish_reason;
615
- if (corte)
651
+ if (corte) {
616
652
  motivoDeCorte = corte;
653
+ llegoElFinal = true;
654
+ }
617
655
  // Track streaming usage metrics
618
656
  if (chunk.usage) {
657
+ llegoElFinal = true;
619
658
  this.tracker.addUsage(chunk.usage);
620
659
  turnPromptTokens += chunk.usage.prompt_tokens || 0;
621
660
  if (chunk.usage.prompt_tokens)
@@ -639,10 +678,12 @@ export class AgentLoop {
639
678
  salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
640
679
  return parada("interrumpido");
641
680
  }
681
+ if (esDemasiadasEnCurso(streamErr))
682
+ anotarHuecosOcupados(streamErr?.error?.retry_after_s);
642
683
  const claroStream = explicarErrorDelMotor(streamErr);
643
684
  if (claroStream) {
644
- salida.linea(chalk.yellow(`
645
- ⏸ ${claroStream}
685
+ salida.linea(chalk.yellow(`
686
+ ⏸ ${claroStream}
646
687
  `));
647
688
  return parada(motivoDeParadaDelError(streamErr), claroStream);
648
689
  }
@@ -662,6 +703,49 @@ export class AgentLoop {
662
703
  }
663
704
  // Filter valid accumulated tool calls
664
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
+ }
665
749
  // El modelo se quedo sin presupuesto de salida y no llego a pedir nada.
666
750
  //
667
751
  // Esto se veia EXACTAMENTE igual que un turno terminado: sin llamadas a
@@ -910,7 +994,7 @@ export class AgentLoop {
910
994
  toolResults.push({
911
995
  role: "tool",
912
996
  tool_call_id: tc.id,
913
- content: post.additionalContext ? `${texto}\n\n
997
+ content: post.additionalContext ? `${texto}\n\n
914
998
  [Hook PostToolUse]: ${post.additionalContext}` : texto,
915
999
  });
916
1000
  }
@@ -1,35 +1,21 @@
1
+ import { escribirEncimaDelPie } from "../ui/pieFijo.js";
1
2
  /**
2
- * Por donde habla el bucle del agente.
3
+ * Pasa por `pieFijo` y no por `console.log` a secas.
3
4
  *
4
- * POR QUE
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.
5
10
  *
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.
11
+ * Sin pie puesto -que es casi todo el rato- esto es un `write` normal.
26
12
  */
27
13
  const A_LA_TERMINAL = {
28
14
  linea: (texto) => {
29
- console.log(texto);
15
+ escribirEncimaDelPie(`${texto}\n`);
30
16
  },
31
17
  crudo: (texto) => {
32
- process.stdout.write(texto);
18
+ escribirEncimaDelPie(texto);
33
19
  },
34
20
  };
35
21
  let actual = A_LA_TERMINAL;
@@ -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.
@@ -55,12 +55,33 @@ export function engineHeaders(route) {
55
55
  cabeceras["X-Chocolatito-Instance"] = license.instanceId;
56
56
  return cabeceras;
57
57
  }
58
- /** Cliente del motor ya configurado. Es el unico sitio donde se construye. */
58
+ /**
59
+ * Cliente del motor ya configurado. Es el unico sitio donde se construye.
60
+ *
61
+ * POR QUE `maxRetries: 0`
62
+ *
63
+ * El SDK reintenta por su cuenta dos veces mas (su valor por defecto), y encima
64
+ * de eso el agente pasa cada llamada por `withRetry`. Dos capas apiladas, y solo
65
+ * una de las dos sabe algo del proxy.
66
+ *
67
+ * Medido contra un servidor de mentira: una sola llamada que recibe un 429 de
68
+ * "tienes 4 peticiones en curso" sale por la red TRES veces antes de que el
69
+ * error llegue siquiera a nuestro codigo. Y nuestro codigo lo tiene bien
70
+ * clasificado -`isRetryable` devuelve false para ese 429 justamente porque cada
71
+ * intento ocupa un hueco-, pero para entonces ya se han gastado los otros dos.
72
+ *
73
+ * Asi es como un solo corte de conexion se comia los cuatro huecos de la
74
+ * licencia y dejaba al usuario tres minutos sin servicio. La politica de
75
+ * reintentos vive en `withRetry` y en un solo sitio: ahi se sabe que un
76
+ * desbordamiento de contexto no puede empezar a caber, y que insistir con los
77
+ * huecos ocupados es la forma mas rapida de quedarse sin ninguno.
78
+ */
59
79
  export function createEngineClient(apiKey, route) {
60
80
  return new OpenAI({
61
81
  apiKey: engineCredential(apiKey),
62
82
  baseURL: getEngineBaseUrl(),
63
83
  defaultHeaders: engineHeaders(route),
84
+ maxRetries: 0,
64
85
  });
65
86
  }
66
87
  export function clasificarErrorDelMotor(err) {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { WebSocketServer, WebSocket } from "ws";
5
6
  import { describeScreen } from "./visionBridge.js";
6
7
  /**
@@ -278,6 +279,55 @@ function emparejar(objetivo, origen, destino) {
278
279
  return candidatos[pos].ref;
279
280
  return null;
280
281
  }
282
+ /**
283
+ * Avisar cuando la extension de Chrome se quedo atras.
284
+ *
285
+ * POR QUE HACE FALTA
286
+ *
287
+ * El CLI se actualiza solo, y la extension viaja DENTRO del paquete npm. Asi
288
+ * que al actualizarse, los archivos de la extension en el disco pasan a ser
289
+ * nuevos... pero Chrome sigue ejecutando los que cargo en su dia. No se entera
290
+ * hasta que alguien pulsa "recargar" en chrome://extensions.
291
+ *
292
+ * Eso deja al usuario con una version del CLI que espera cosas que su extension
293
+ * no sabe hacer, y los fallos que salen de ahi no se parecen en nada a su causa:
294
+ * una captura que llega en PNG cuando el CLI ya cuenta con JPEG, una accion que
295
+ * contesta distinto de lo que se espera. Sintomas raros, causa invisible.
296
+ *
297
+ * La extension ya decia su version en cada `status` y nadie la miraba. Ahora se
298
+ * compara con la que trae este CLI, y si no cuadran se dice una vez.
299
+ */
300
+ let yaAvisadoDeVersion = false;
301
+ /** La version de extension que trae ESTE CLI, leida del paquete instalado. */
302
+ function versionEsperadaDeExtension() {
303
+ try {
304
+ const aqui = path.dirname(fileURLToPath(import.meta.url));
305
+ const manifiesto = path.resolve(aqui, "..", "..", "extension", "manifest.json");
306
+ return String(JSON.parse(fs.readFileSync(manifiesto, "utf-8")).version || "");
307
+ }
308
+ catch {
309
+ // Sin manifiesto no se puede comparar, y adivinar seria peor que callar.
310
+ return "";
311
+ }
312
+ }
313
+ /**
314
+ * Aviso si la extension va por detras. Cadena vacia si todo cuadra o no se sabe.
315
+ *
316
+ * Solo se avisa UNA vez por sesion: repetirlo en cada accion seria ruido que
317
+ * acaba ignorandose, que es como se pierden los avisos que importan.
318
+ */
319
+ function avisoDeVersion(deLaExtension) {
320
+ if (yaAvisadoDeVersion)
321
+ return "";
322
+ const esperada = versionEsperadaDeExtension();
323
+ if (!esperada || !deLaExtension || esperada === deLaExtension)
324
+ return "";
325
+ yaAvisadoDeVersion = true;
326
+ return (`\n\nAVISO: tu extension de Chrome es la v${deLaExtension} y este Chocolatito trae la v${esperada}. ` +
327
+ `Chrome sigue ejecutando la que cargo en su dia aunque los archivos del disco ya sean nuevos. ` +
328
+ `Recargala en chrome://extensions (boton de recargar en la tarjeta de Chocolatito Code) o ` +
329
+ `veras fallos raros que no se parecen a su causa.`);
330
+ }
281
331
  class CatalogoDeRefs {
282
332
  /** Lo que vio el modelo: los numeros en los que habla. */
283
333
  vista = [];
@@ -315,6 +365,10 @@ class CatalogoDeRefs {
315
365
  return this.filtroVista;
316
366
  }
317
367
  /** Nombre que tenia ese ref cuando el modelo lo vio. */
368
+ /** Huella de lo que vio el modelo, para poder comparar despues de actuar. */
369
+ huellaVista() {
370
+ return this.vista.map((e) => `${e.role}::${e.name}`);
371
+ }
318
372
  nombreDe(ref) {
319
373
  return this.vista.find((e) => e.ref === ref)?.name || "";
320
374
  }
@@ -353,6 +407,61 @@ async function refrescarCatalogo(filtro) {
353
407
  catalogo.verInterno(fresh.data.elements || []);
354
408
  return true;
355
409
  }
410
+ /**
411
+ * Comprobar que el clic hizo ALGO, en vez de dar por hecho que si.
412
+ *
413
+ * EL FALLO, VISTO EN UNA SESION REAL
414
+ *
415
+ * En Google Flow, el agente pulso el boton de generar y la herramienta contesto:
416
+ *
417
+ * Pulsado [19] "Iniciar generación". Pagina ahora: "Google Flow: sept 06..."
418
+ *
419
+ * El modelo leyo eso como "hecho" y se puso a esperar el resultado. Pero el clic
420
+ * no habia disparado nada: la pagina seguia igual. Espero, no vio nada, volvio a
421
+ * pulsar, volvio a esperar, gasto una captura de pantalla, y solo se desatasco
422
+ * cuando el usuario le dijo a mano "dale al boton de la flechita".
423
+ *
424
+ * El problema no fue del modelo: la herramienta le habia dicho que el clic se
425
+ * habia dado. Y "se envio el evento" no es lo mismo que "la pagina reacciono".
426
+ * Una web puede ignorar un clic sintetico por mil motivos -el manejador esta en
427
+ * otro elemento, hay un overlay delante, el framework escucha pointerdown y no
428
+ * click-, y ninguno de esos se ve desde fuera.
429
+ *
430
+ * QUE SE HACE AHORA
431
+ *
432
+ * Se mira la pagina antes y despues. Si no cambio absolutamente nada, se dice —
433
+ * que es informacion util y no un error: significa "el evento salio pero nadie
434
+ * lo recogio, prueba otra cosa". Es la diferencia entre que el agente se quede
435
+ * quince segundos esperando algo que no va a pasar, y que cambie de estrategia
436
+ * en el turno siguiente.
437
+ */
438
+ async function reaccionAlClic(filtro, antes) {
439
+ let fresh;
440
+ try {
441
+ fresh = await extensionBridge.send("snapshot", { filter: filtro || "", max: 200 });
442
+ }
443
+ catch {
444
+ return "";
445
+ }
446
+ if (!fresh?.ok)
447
+ return "";
448
+ const els = fresh.data.elements || [];
449
+ catalogo.verInterno(els);
450
+ const ahora = els.map((e) => `${e.role}::${e.name}`);
451
+ const nuevos = ahora.filter((k) => !antes.includes(k));
452
+ const idos = antes.filter((k) => !ahora.includes(k));
453
+ if (nuevos.length === 0 && idos.length === 0) {
454
+ return (`\nOJO: el clic se envio, pero la pagina NO cambio en nada. Eso suele significar que ` +
455
+ `el elemento pulsado no es el que dispara la accion (el manejador esta en otro, hay algo ` +
456
+ `delante, o el framework escucha otro evento). No esperes un resultado: haz snapshot y ` +
457
+ `busca el control de verdad, o usa computer_use para un clic real del sistema.`);
458
+ }
459
+ const resumen = [
460
+ ...nuevos.slice(0, 6).map((k) => ` + ${k.replace("::", " ")}`),
461
+ ...idos.slice(0, 4).map((k) => ` - ${k.replace("::", " ")} (ya no esta)`),
462
+ ].join("\n");
463
+ return `\nLa pagina reacciono (${nuevos.length} nuevos, ${idos.length} fuera):\n${resumen}`;
464
+ }
356
465
  /**
357
466
  * Ejecuta una accion sobre un ref del snapshot del modelo, traduciendolo al
358
467
  * numero que la extension tiene vivo ahora mismo. Si el ref ha caducado (el
@@ -421,7 +530,8 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
421
530
  `Si no la reconoces, revisala en chrome://extensions.`
422
531
  : "";
423
532
  return (`Extension conectada (v${res.data.version}), emparejada con ${origin}. ` +
424
- `Puedo trabajar en tus pestanas en segundo plano, sin robarte el foco.${aviso}`);
533
+ `Puedo trabajar en tus pestanas en segundo plano, sin robarte el foco.${aviso}` +
534
+ avisoDeVersion(String(res.data.version || "")));
425
535
  }
426
536
  case "tabs": {
427
537
  const res = await extensionBridge.send("tabs");
@@ -500,6 +610,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
500
610
  case "click": {
501
611
  if (params.ref === undefined)
502
612
  return fail(action, 'indica el "ref" del snapshot.');
613
+ // Se apunta como estaba la pagina ANTES: sin eso no hay con que comparar
614
+ // despues, y "se envio el clic" seguiria pasando por "la pagina reacciono".
615
+ const antesDelClic = catalogo.huellaVista();
503
616
  const res = await accionSobreRef("click", {}, params.ref, params.filter);
504
617
  if (!res.ok)
505
618
  return fail(action, res.error);
@@ -512,7 +625,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
512
625
  const aviso = mismoElemento(pedido, pulsado)
513
626
  ? ""
514
627
  : `\nAVISO: pediste "${pedido}" y se pulso "${pulsado}". Haz snapshot y comprueba en que estado quedo la pagina.`;
515
- return (`Pulsado [${params.ref}] "${pulsado}". Pagina ahora: "${res.data.title}" (${res.data.url})${aviso}`);
628
+ // Y lo que de verdad importa: si la pagina hizo algo o no. Ver reaccionAlClic.
629
+ const reaccion = antesDelClic.length > 0 ? await reaccionAlClic(params.filter || "", antesDelClic) : "";
630
+ return (`Pulsado [${params.ref}] "${pulsado}". Pagina ahora: "${res.data.title}" (${res.data.url})${aviso}${reaccion}`);
516
631
  }
517
632
  case "type": {
518
633
  if (params.ref === undefined)
@@ -651,7 +766,7 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
651
766
  ? path.isAbsolute(params.outputPath)
652
767
  ? params.outputPath
653
768
  : path.resolve(cwd, params.outputPath)
654
- : path.join(dir, "chrome_tab.png");
769
+ : path.join(dir, "chrome_tab.jpg");
655
770
  const outDir = path.dirname(out);
656
771
  if (!fs.existsSync(outDir))
657
772
  fs.mkdirSync(outDir, { recursive: true });
@@ -23,7 +23,7 @@
23
23
  * "background": se actua sobre una ventana concreta sin traerla al frente y
24
24
  * sin mover el raton del usuario.
25
25
  */
26
- export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "scroll" | "type" | "key" | "hotkey" | "wait" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
26
+ export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "scroll" | "type" | "key" | "hotkey" | "wait" | "wait_change" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
27
27
  export interface ComputerUseParams {
28
28
  action: ComputerAction;
29
29
  /** Ventana objetivo por titulo o proceso (ej. "chrome", "Flow"). */