create-panal-agent 0.11.0 → 0.13.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/dist/i18n.js +10 -0
- package/dist/index.js +4 -0
- package/package.json +6 -4
- package/template/_package.json +2 -1
- package/template/src/adjuntos.ts +359 -0
- package/template/src/agent.ts +67 -41
- package/template/src/memoria.ts +136 -0
- package/template/src/reintento.ts +68 -0
- package/template/src/salida.ts +391 -0
- package/template/src/server.ts +16 -0
- package/template/src/zip.ts +245 -0
package/template/src/agent.ts
CHANGED
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
* anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
|
|
15
|
+
import { leerAdjuntos, type AdjuntoRecibido, type AdjuntosLeidos } from './adjuntos.js';
|
|
16
|
+
import { comoArchivo, formatoPedido } from './salida.js';
|
|
17
|
+
import { historialComoTexto, type Turno } from './memoria.js';
|
|
16
18
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
bytes: Uint8Array;
|
|
25
|
-
}
|
|
19
|
+
/**
|
|
20
|
+
* Un archivo que el CLIENTE te mandó con el encargo.
|
|
21
|
+
*
|
|
22
|
+
* El tipo vive en `adjuntos.js`, que es quien sabe abrirlos; se reexporta aquí
|
|
23
|
+
* para tenerlo a mano sin salir de este archivo.
|
|
24
|
+
*/
|
|
25
|
+
export type { AdjuntoRecibido };
|
|
26
26
|
|
|
27
27
|
export interface TaskContext {
|
|
28
28
|
/**
|
|
@@ -44,8 +44,11 @@ export interface TaskContext {
|
|
|
44
44
|
* pagara, así que estos bytes son exactamente los que la cadena cubre — si
|
|
45
45
|
* alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* El motor los ABRE por ti antes de llamarte: las imágenes se le enseñan al
|
|
48
|
+
* modelo, y de un PDF, un Word, un Excel o una carpeta comprimida se saca el
|
|
49
|
+
* texto y entra en el encargo. Lo que no se pueda abrir se le NOMBRA al
|
|
50
|
+
* modelo en vez de callarlo. Aquí los tienes en crudo por si tu agente sabe
|
|
51
|
+
* hacer algo más con ellos.
|
|
49
52
|
*
|
|
50
53
|
* Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
|
|
51
54
|
*/
|
|
@@ -85,6 +88,23 @@ export interface TaskContext {
|
|
|
85
88
|
* `consultar` ya lo respeta por su cuenta.
|
|
86
89
|
*/
|
|
87
90
|
envelope: CallEnvelope | null;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* LO QUE YA SE HABLÓ CON ESTA PERSONA.
|
|
94
|
+
*
|
|
95
|
+
* Sólo en llamadas x402, que son las que forman una conversación. En un
|
|
96
|
+
* encargo del escrow va vacío: un encargo tiene principio y fin, y
|
|
97
|
+
* arrastrarle memoria sería confundir dos cosas distintas.
|
|
98
|
+
*
|
|
99
|
+
* Quién es "esta persona" lo dice el PAGO, no una cabecera: firmó un permiso
|
|
100
|
+
* y el cobro se ejecutó en la cadena, así que nadie puede continuar la
|
|
101
|
+
* conversación de otro sin haber pagado como él.
|
|
102
|
+
*
|
|
103
|
+
* Va acotado por turnos y por caracteres (`MEMORIA_TURNOS`, `MEMORIA_CHARS`)
|
|
104
|
+
* porque entra en el prompt, y el prompt lo pagas TÚ mientras el cliente
|
|
105
|
+
* paga un precio fijo por mensaje.
|
|
106
|
+
*/
|
|
107
|
+
historial: Turno[];
|
|
88
108
|
}
|
|
89
109
|
|
|
90
110
|
/** Un archivo que entregas junto al texto. */
|
|
@@ -156,14 +176,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
156
176
|
);
|
|
157
177
|
}
|
|
158
178
|
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
const
|
|
162
|
-
.filter((a) => esImagenSoportada(a.mime))
|
|
163
|
-
.map((a) => ({ mime: a.mime!, bytes: a.bytes }));
|
|
179
|
+
// Se abre TODO lo que mandó el cliente: imágenes para que las mire el
|
|
180
|
+
// modelo, y el texto de los PDF, Word, Excel y carpetas que vengan dentro.
|
|
181
|
+
const leido = await leerAdjuntos(ctx.adjuntos);
|
|
164
182
|
if (ctx.adjuntos.length > 0) {
|
|
165
183
|
console.log(
|
|
166
|
-
`[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${imagenes.length} para el modelo: ` +
|
|
184
|
+
`[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${leido.imagenes.length} para el modelo: ` +
|
|
167
185
|
ctx.adjuntos.map((a) => a.name).join(', '),
|
|
168
186
|
);
|
|
169
187
|
}
|
|
@@ -177,11 +195,11 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
177
195
|
// que entregues queda anclado en la cadena y ya no se puede rectificar.
|
|
178
196
|
let queja: string | null = null;
|
|
179
197
|
for (let intento = 1; intento <= 2; intento++) {
|
|
180
|
-
const texto = await pedirAlModelo(brief, cfg, queja, ayuda,
|
|
198
|
+
const texto = await pedirAlModelo(brief, cfg, queja, ayuda, leido, ctx.historial);
|
|
181
199
|
const problema = revisar(brief, texto);
|
|
182
200
|
if (!problema) {
|
|
183
201
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
184
|
-
return
|
|
202
|
+
return conArchivoSiLoPidio(brief, texto, ctx);
|
|
185
203
|
}
|
|
186
204
|
console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
|
|
187
205
|
// A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
|
|
@@ -189,7 +207,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
189
207
|
// entregar algo imperfecto y que él decida, que dejarlo sin nada.
|
|
190
208
|
if (intento === 2) {
|
|
191
209
|
console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
|
|
192
|
-
return
|
|
210
|
+
return conArchivoSiLoPidio(brief, texto, ctx);
|
|
193
211
|
}
|
|
194
212
|
queja = problema;
|
|
195
213
|
}
|
|
@@ -354,11 +372,15 @@ function revisar(brief: string, resultado: string): string | null {
|
|
|
354
372
|
* lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
|
|
355
373
|
* se baja es exactamente el que le entregaste.
|
|
356
374
|
*/
|
|
357
|
-
function
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
375
|
+
function conArchivoSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
|
|
376
|
+
const formato = formatoPedido(brief);
|
|
377
|
+
if (!formato) return texto;
|
|
378
|
+
const archivo = comoArchivo(formato, 'entrega', `Panal - entrega ${etiqueta(ctx)}`, texto);
|
|
379
|
+
const bytes = typeof archivo.data === 'string' ? archivo.data.length : archivo.data.byteLength;
|
|
380
|
+
console.log(`[agente] ${etiqueta(ctx)} ${archivo.name} de ${bytes} bytes adjunto`);
|
|
381
|
+
// El TEXTO se sigue entregando: es lo que se ancla en la cadena. El archivo
|
|
382
|
+
// va además, y su hash viaja dentro de la entrega.
|
|
383
|
+
return { text: texto, files: [archivo] };
|
|
362
384
|
}
|
|
363
385
|
|
|
364
386
|
async function pedirAlModelo(
|
|
@@ -366,27 +388,31 @@ async function pedirAlModelo(
|
|
|
366
388
|
cfg: LlmConfig,
|
|
367
389
|
queja: string | null,
|
|
368
390
|
ayuda: string | null,
|
|
369
|
-
|
|
370
|
-
|
|
391
|
+
leido: AdjuntosLeidos,
|
|
392
|
+
historial: Turno[],
|
|
371
393
|
): Promise<string> {
|
|
372
394
|
// Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
|
|
373
395
|
// dialectos aceptan varios turnos, pero cada uno los cuenta a su manera, y
|
|
374
396
|
// lo que aquí importa no es de quién es cada mensaje: es que el modelo no
|
|
375
397
|
// confunda el material de apoyo con el encargo.
|
|
376
|
-
const partes = [
|
|
398
|
+
const partes: string[] = [];
|
|
377
399
|
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
partes.push(
|
|
384
|
-
`[El cliente adjuntó estos archivos, que no puedes abrir: ${noMirables
|
|
385
|
-
.map((a) => `${a.name}${a.mime ? ` (${a.mime})` : ''}`)
|
|
386
|
-
.join(', ')}. El agente los tiene y los procesa aparte.]`,
|
|
387
|
-
);
|
|
400
|
+
// Lo hablado antes va PRIMERO y marcado como tal. Sin la etiqueta, el modelo
|
|
401
|
+
// confunde una pregunta vieja con la de ahora y contesta a la equivocada;
|
|
402
|
+
// con ella entiende que es contexto y que lo último es lo que se le pide.
|
|
403
|
+
const antes = historialComoTexto(historial);
|
|
404
|
+
if (antes) {
|
|
405
|
+
partes.push(`Conversación anterior con este cliente, para que sepas de qué habláis:\n\n${antes}`);
|
|
388
406
|
}
|
|
389
407
|
|
|
408
|
+
partes.push(historial.length > 0 ? `Ahora te pide:\n${brief}` : brief);
|
|
409
|
+
|
|
410
|
+
// Lo que se pudo sacar de los adjuntos, ya etiquetado: el texto de un PDF,
|
|
411
|
+
// las filas de un Excel, los archivos de una carpeta. Y lo que NO se pudo
|
|
412
|
+
// abrir, nombrado — callarlo hace que el modelo conteste como si el cliente
|
|
413
|
+
// no hubiera mandado nada.
|
|
414
|
+
if (leido.texto) partes.push(leido.texto);
|
|
415
|
+
|
|
390
416
|
// Lo que contestó el especialista, si se le preguntó. Va marcado como
|
|
391
417
|
// material de apoyo y no como parte del encargo: sin esa aclaración el
|
|
392
418
|
// modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
|
|
@@ -436,7 +462,7 @@ async function pedirAlModelo(
|
|
|
436
462
|
'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
|
|
437
463
|
'and should refer to, because the client knows they sent them.',
|
|
438
464
|
user: partes.join('\n\n'),
|
|
439
|
-
...(imagenes.length > 0 ? { imagenes } : {}),
|
|
465
|
+
...(leido.imagenes.length > 0 ? { imagenes: leido.imagenes } : {}),
|
|
440
466
|
},
|
|
441
467
|
);
|
|
442
468
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La memoria de una conversación.
|
|
3
|
+
*
|
|
4
|
+
* Sin esto, cada llamada x402 es independiente: el cliente pregunta algo, tu
|
|
5
|
+
* agente contesta, y a la siguiente no sabe de qué se hablaba. Eso no es un
|
|
6
|
+
* chat, es un buscador que pasa factura — y quien lo usa lo nota al segundo
|
|
7
|
+
* mensaje, cuando tiene que repetir el contexto entero.
|
|
8
|
+
*
|
|
9
|
+
* QUIÉN HABLA LO DICE EL PAGO. La conversación se guarda por la dirección del
|
|
10
|
+
* pagador, y esa dirección no la afirma nadie: firmó un permiso y el cobro se
|
|
11
|
+
* ejecutó en la cadena. Nadie puede leer ni continuar la conversación de otro
|
|
12
|
+
* sin haber pagado como él, así que no hace falta ninguna autenticación
|
|
13
|
+
* aparte. Es la propiedad más útil de cobrar por llamada.
|
|
14
|
+
*
|
|
15
|
+
* SOLO EN x402, NO EN EL ESCROW. Un encargo del escrow es un trabajo con
|
|
16
|
+
* principio y fin: se paga, se entrega una vez y se aprueba. Arrastrarle
|
|
17
|
+
* memoria sería confundir dos cosas distintas.
|
|
18
|
+
*
|
|
19
|
+
* LO QUE CUESTA, porque conviene tenerlo delante: el historial va dentro del
|
|
20
|
+
* prompt, y el prompt lo pagas TÚ mientras el cliente paga un precio fijo por
|
|
21
|
+
* mensaje. Una conversación larga es cada vez más cara de contestar por lo
|
|
22
|
+
* mismo. De ahí los dos topes de abajo, y de ahí que se puedan bajar.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** Un intercambio ya cerrado: lo que preguntaron y lo que contestaste. */
|
|
29
|
+
export interface Turno {
|
|
30
|
+
pregunta: string;
|
|
31
|
+
respuesta: string;
|
|
32
|
+
/** Epoch en milisegundos. */
|
|
33
|
+
cuando: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cuántos turnos se recuerdan. `MEMORIA_TURNOS=0` apaga la memoria.
|
|
38
|
+
*
|
|
39
|
+
* Seis es un número corto a propósito: cubre una conversación normal y deja
|
|
40
|
+
* el coste acotado. Súbelo si tu agente necesita hilos largos y te sale a
|
|
41
|
+
* cuenta; bájalo a cero si lo tuyo son preguntas sueltas.
|
|
42
|
+
*/
|
|
43
|
+
const TURNOS = (() => {
|
|
44
|
+
const n = Number(process.env.MEMORIA_TURNOS?.trim() || '6');
|
|
45
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 6;
|
|
46
|
+
})();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tope de caracteres del historial que entra en el prompt.
|
|
50
|
+
*
|
|
51
|
+
* El tope de turnos por sí solo no acota nada: seis turnos pueden ser seis
|
|
52
|
+
* líneas o seis pantallas de código pegado.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_CHARS = (() => {
|
|
55
|
+
const n = Number(process.env.MEMORIA_CHARS?.trim() || '4000');
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 4000;
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
/** Cuántos turnos se guardan en disco, que es más de lo que se manda. */
|
|
60
|
+
const GUARDADOS = 60;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* El archivo de una conversación.
|
|
64
|
+
*
|
|
65
|
+
* El nombre sale de la dirección, y aunque venga ya validada como tal se
|
|
66
|
+
* limpia igual: es lo que decide una ruta en disco, y una comprobación de más
|
|
67
|
+
* en un sitio así no cuesta nada.
|
|
68
|
+
*/
|
|
69
|
+
function archivo(dataDir: string, quien: string): string {
|
|
70
|
+
const limpio = quien.toLowerCase().replace(/[^a-z0-9x]/g, '');
|
|
71
|
+
return join(dataDir, 'chats', `${limpio}.json`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Todo lo que se recuerda de esa persona, de lo más viejo a lo más nuevo. */
|
|
75
|
+
export function leerConversacion(dataDir: string, quien: string): Turno[] {
|
|
76
|
+
try {
|
|
77
|
+
const turnos = JSON.parse(readFileSync(archivo(dataDir, quien), 'utf8')) as Turno[];
|
|
78
|
+
return Array.isArray(turnos) ? turnos : [];
|
|
79
|
+
} catch {
|
|
80
|
+
// Sin archivo, o con un archivo ilegible: se empieza de cero. Una memoria
|
|
81
|
+
// rota no puede impedir contestar a alguien que acaba de pagar.
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Guarda un intercambio.
|
|
88
|
+
*
|
|
89
|
+
* Se llama DESPUÉS de contestar, con las dos mitades: un turno con pregunta y
|
|
90
|
+
* sin respuesta ensucia la memoria de la siguiente vez, y es justo lo que
|
|
91
|
+
* pasaría si se guardara antes de trabajar y el modelo fallara.
|
|
92
|
+
*/
|
|
93
|
+
export function recordarTurno(dataDir: string, quien: string, turno: Turno): void {
|
|
94
|
+
if (TURNOS === 0) return;
|
|
95
|
+
try {
|
|
96
|
+
const previos = leerConversacion(dataDir, quien);
|
|
97
|
+
mkdirSync(join(dataDir, 'chats'), { recursive: true });
|
|
98
|
+
writeFileSync(archivo(dataDir, quien), JSON.stringify([...previos, turno].slice(-GUARDADOS)), 'utf8');
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Perder la memoria no puede tumbar una respuesta ya cobrada.
|
|
101
|
+
console.error(`[memoria] no se pudo guardar el turno: ${err instanceof Error ? err.message : err}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* El historial que se le pasa al modelo, ya acotado.
|
|
107
|
+
*
|
|
108
|
+
* Se recorta desde el final: lo reciente es lo que da contexto, y lo viejo es
|
|
109
|
+
* lo primero que sobra. Devuelve vacío con la memoria apagada.
|
|
110
|
+
*/
|
|
111
|
+
export function historialParaElModelo(dataDir: string, quien: string): Turno[] {
|
|
112
|
+
if (TURNOS === 0) return [];
|
|
113
|
+
|
|
114
|
+
const recientes = leerConversacion(dataDir, quien).slice(-TURNOS);
|
|
115
|
+
const salida: Turno[] = [];
|
|
116
|
+
let chars = 0;
|
|
117
|
+
|
|
118
|
+
// Se recorren del más nuevo al más viejo para que, si hay que dejar fuera
|
|
119
|
+
// algo, sea lo antiguo. Luego se devuelve en orden cronológico.
|
|
120
|
+
for (let i = recientes.length - 1; i >= 0; i--) {
|
|
121
|
+
const t = recientes[i]!;
|
|
122
|
+
const coste = t.pregunta.length + t.respuesta.length;
|
|
123
|
+
if (chars + coste > MAX_CHARS) break;
|
|
124
|
+
chars += coste;
|
|
125
|
+
salida.unshift(t);
|
|
126
|
+
}
|
|
127
|
+
return salida;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Cómo se le cuenta el historial al modelo. Vacío si no hay nada que contar. */
|
|
131
|
+
export function historialComoTexto(turnos: Turno[]): string {
|
|
132
|
+
if (turnos.length === 0) return '';
|
|
133
|
+
return turnos
|
|
134
|
+
.map((t) => `Cliente: ${t.pregunta}\nTú: ${t.respuesta}`)
|
|
135
|
+
.join('\n\n');
|
|
136
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reintentar cuando el modelo dice «ahora no».
|
|
3
|
+
*
|
|
4
|
+
* POR QUÉ EXISTE. Un proveedor devuelve 429 cuando está saturado o cuando has
|
|
5
|
+
* ido demasiado deprisa, y 5xx cuando le pasa algo a él. Ninguna de las dos es
|
|
6
|
+
* culpa tuya y las dos se arreglan solas en segundos. Sin esto, cualquiera de
|
|
7
|
+
* ellas mata la tarea: el cliente ya pagó, el pago se queda bloqueado, y no
|
|
8
|
+
* recupera nada hasta que vence el plazo.
|
|
9
|
+
*
|
|
10
|
+
* Medido contra Moonshot en agosto de 2026: dos llamadas seguidas y la segunda
|
|
11
|
+
* volvió con `engine_overloaded_error`. No es un caso raro que haya que
|
|
12
|
+
* imaginar, es lo normal en una tarde con tráfico.
|
|
13
|
+
*
|
|
14
|
+
* QUÉ **NO** SE REINTENTA, que importa igual: los demás 4xx. Un 401 es una
|
|
15
|
+
* clave mala, un 400 es una petición mal formada y un 404 es un modelo que no
|
|
16
|
+
* existe. Repetirlos gasta tiempo y dinero para llegar a la misma respuesta, y
|
|
17
|
+
* retrasa la única noticia útil —que hay algo mal configurado— hasta que se
|
|
18
|
+
* agotan los intentos.
|
|
19
|
+
*
|
|
20
|
+
* La espera CRECE entre intentos. Volver a preguntar de inmediato a un motor
|
|
21
|
+
* saturado es pedirle el mismo 429: empuja más carga justo cuando menos puede
|
|
22
|
+
* con ella.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Esperas entre intentos, en milisegundos. Cuatro llamadas como mucho. */
|
|
26
|
+
const ESPERAS = [1_000, 4_000, 10_000];
|
|
27
|
+
|
|
28
|
+
/** Los códigos que se vuelven a intentar. El resto son respuestas firmes. */
|
|
29
|
+
export function esReintentable(status: number): boolean {
|
|
30
|
+
return status === 429 || status >= 500;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `fetch` que aguanta un proveedor con hipo.
|
|
35
|
+
*
|
|
36
|
+
* Devuelve la última respuesta, reintentable o no: quien llama decide qué
|
|
37
|
+
* hacer con ella, como con un `fetch` normal. Los fallos de RED sí se
|
|
38
|
+
* propagan al agotar los intentos, porque ahí no hay respuesta que devolver.
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchModelo(
|
|
41
|
+
url: string,
|
|
42
|
+
init: RequestInit,
|
|
43
|
+
dormir: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
let ultimoFallo: unknown;
|
|
46
|
+
|
|
47
|
+
for (let intento = 0; intento <= ESPERAS.length; intento++) {
|
|
48
|
+
if (intento > 0) {
|
|
49
|
+
const espera = ESPERAS[intento - 1]!;
|
|
50
|
+
console.warn(`[modelo] reintento ${intento} de ${ESPERAS.length} dentro de ${espera} ms`);
|
|
51
|
+
await dormir(espera);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(url, init);
|
|
55
|
+
if (!esReintentable(res.status) || intento === ESPERAS.length) return res;
|
|
56
|
+
console.warn(`[modelo] el proveedor respondió ${res.status}`);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Un corte de red o el timeout del AbortSignal. Se reintenta igual: son
|
|
59
|
+
// tan pasajeros como un 503, y con el pago bloqueado no rendirse al
|
|
60
|
+
// primer tropiezo es lo que separa entregar de no entregar.
|
|
61
|
+
ultimoFallo = err;
|
|
62
|
+
if (intento === ESPERAS.length) throw err;
|
|
63
|
+
console.warn(`[modelo] la llamada falló: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw ultimoFallo ?? new Error('inalcanzable');
|
|
68
|
+
}
|