create-panal-agent 0.10.0 → 0.12.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 +18 -0
- package/dist/i18n.js +20 -10
- package/dist/index.js +8 -5
- package/package.json +2 -2
- package/template/_package.json +1 -1
- package/template/src/agent.ts +188 -101
- package/template/src/memoria.ts +136 -0
- package/template/src/reintento.ts +68 -0
- package/template/src/server.ts +311 -5
package/template/src/agent.ts
CHANGED
|
@@ -11,8 +11,19 @@
|
|
|
11
11
|
* anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type
|
|
14
|
+
import { esImagenSoportada, llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
|
|
15
15
|
import { textoAPdf } from './pdf.js';
|
|
16
|
+
import { historialComoTexto, type Turno } from './memoria.js';
|
|
17
|
+
|
|
18
|
+
/** Un archivo que el CLIENTE te mandó con el encargo. */
|
|
19
|
+
export interface AdjuntoRecibido {
|
|
20
|
+
/** Nombre ya limpio, tal y como lo anunció el encargo. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Tipo MIME, si lo declaró: `image/png`, `application/pdf`… */
|
|
23
|
+
mime?: string;
|
|
24
|
+
/** El contenido. Su hash ya se comprobó contra lo que la cadena cubre. */
|
|
25
|
+
bytes: Uint8Array;
|
|
26
|
+
}
|
|
16
27
|
|
|
17
28
|
export interface TaskContext {
|
|
18
29
|
/**
|
|
@@ -27,6 +38,20 @@ export interface TaskContext {
|
|
|
27
38
|
/** Fecha límite de entrega, en segundos epoch. Cero en una llamada x402. */
|
|
28
39
|
deadline: bigint;
|
|
29
40
|
|
|
41
|
+
/**
|
|
42
|
+
* LO QUE EL CLIENTE TE ADJUNTÓ: una foto, un PDF que revisar, un CSV.
|
|
43
|
+
*
|
|
44
|
+
* Llegan verificados. El encargo anunció el hash de cada uno ANTES de que se
|
|
45
|
+
* pagara, así que estos bytes son exactamente los que la cadena cubre — si
|
|
46
|
+
* alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
|
|
47
|
+
*
|
|
48
|
+
* Las imágenes se le pasan solas al modelo, si el tuyo sabe mirarlas. El
|
|
49
|
+
* resto lo tienes aquí en crudo para hacer lo que sepas hacer con ello.
|
|
50
|
+
*
|
|
51
|
+
* Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
|
|
52
|
+
*/
|
|
53
|
+
adjuntos: AdjuntoRecibido[];
|
|
54
|
+
|
|
30
55
|
/**
|
|
31
56
|
* PREGUNTAR A OTRO AGENTE, Y PAGARLE.
|
|
32
57
|
*
|
|
@@ -61,6 +86,23 @@ export interface TaskContext {
|
|
|
61
86
|
* `consultar` ya lo respeta por su cuenta.
|
|
62
87
|
*/
|
|
63
88
|
envelope: CallEnvelope | null;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* LO QUE YA SE HABLÓ CON ESTA PERSONA.
|
|
92
|
+
*
|
|
93
|
+
* Sólo en llamadas x402, que son las que forman una conversación. En un
|
|
94
|
+
* encargo del escrow va vacío: un encargo tiene principio y fin, y
|
|
95
|
+
* arrastrarle memoria sería confundir dos cosas distintas.
|
|
96
|
+
*
|
|
97
|
+
* Quién es "esta persona" lo dice el PAGO, no una cabecera: firmó un permiso
|
|
98
|
+
* y el cobro se ejecutó en la cadena, así que nadie puede continuar la
|
|
99
|
+
* conversación de otro sin haber pagado como él.
|
|
100
|
+
*
|
|
101
|
+
* Va acotado por turnos y por caracteres (`MEMORIA_TURNOS`, `MEMORIA_CHARS`)
|
|
102
|
+
* porque entra en el prompt, y el prompt lo pagas TÚ mientras el cliente
|
|
103
|
+
* paga un precio fijo por mensaje.
|
|
104
|
+
*/
|
|
105
|
+
historial: Turno[];
|
|
64
106
|
}
|
|
65
107
|
|
|
66
108
|
/** Un archivo que entregas junto al texto. */
|
|
@@ -115,10 +157,15 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
115
157
|
// código, consultar una API, lo que quieras. No tiene por qué usar un modelo.
|
|
116
158
|
// ──────────────────────────────────────────────────────────────────────────
|
|
117
159
|
|
|
118
|
-
|
|
119
|
-
|
|
160
|
+
// El modelo, sea cual sea. `LLM_PROVIDER=claude|kimi|grok|glm|gemini|…` o
|
|
161
|
+
// `LLM_BASE_URL` a pelo para cualquiera que no esté en la lista.
|
|
162
|
+
let cfg: LlmConfig;
|
|
163
|
+
try {
|
|
164
|
+
cfg = resolverLlm(process.env);
|
|
165
|
+
} catch (err) {
|
|
120
166
|
// Sin modelo configurado se entrega algo honesto en vez de fallar: el
|
|
121
167
|
// cliente ya pagó, y dejarlo sin nada le cuesta el plazo entero.
|
|
168
|
+
console.error(`[agente] ${etiqueta(ctx)} sin modelo: ${err instanceof Error ? err.message : err}`);
|
|
122
169
|
return (
|
|
123
170
|
`No puedo completar este encargo ahora mismo: al agente le falta configurar su modelo.\n\n` +
|
|
124
171
|
`Lo que pediste:\n${brief}\n\n` +
|
|
@@ -127,16 +174,28 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
127
174
|
);
|
|
128
175
|
}
|
|
129
176
|
|
|
177
|
+
// Lo que el cliente adjuntó y un modelo puede MIRAR. El resto de adjuntos
|
|
178
|
+
// sigue en `ctx.adjuntos` para que hagas con ellos lo que sepas hacer.
|
|
179
|
+
const imagenes = ctx.adjuntos
|
|
180
|
+
.filter((a) => esImagenSoportada(a.mime))
|
|
181
|
+
.map((a) => ({ mime: a.mime!, bytes: a.bytes }));
|
|
182
|
+
if (ctx.adjuntos.length > 0) {
|
|
183
|
+
console.log(
|
|
184
|
+
`[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${imagenes.length} para el modelo: ` +
|
|
185
|
+
ctx.adjuntos.map((a) => a.name).join(', '),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
130
189
|
// ¿Esto lo sé hacer yo, o me conviene preguntar? La decisión es del agente,
|
|
131
190
|
// no del cliente: él pidió un trabajo, no una arquitectura.
|
|
132
|
-
const ayuda = await pedirAyudaSiHaceFalta(brief, ctx,
|
|
191
|
+
const ayuda = await pedirAyudaSiHaceFalta(brief, ctx, cfg);
|
|
133
192
|
|
|
134
193
|
// Un intento, una revisión y una corrección. Un modelo falla el formato de
|
|
135
194
|
// vez en cuando, y aquí eso no es un mensaje feo en un chat: el hash de lo
|
|
136
195
|
// que entregues queda anclado en la cadena y ya no se puede rectificar.
|
|
137
196
|
let queja: string | null = null;
|
|
138
197
|
for (let intento = 1; intento <= 2; intento++) {
|
|
139
|
-
const texto = await pedirAlModelo(brief,
|
|
198
|
+
const texto = await pedirAlModelo(brief, cfg, queja, ayuda, imagenes, ctx.adjuntos, ctx.historial);
|
|
140
199
|
const problema = revisar(brief, texto);
|
|
141
200
|
if (!problema) {
|
|
142
201
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
@@ -172,12 +231,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
172
231
|
async function pedirAyudaSiHaceFalta(
|
|
173
232
|
brief: string,
|
|
174
233
|
ctx: TaskContext,
|
|
175
|
-
|
|
234
|
+
cfg: LlmConfig,
|
|
176
235
|
): Promise<string | null> {
|
|
177
236
|
// Sin presupuesto no hay nada que decidir, y así se ahorra la llamada.
|
|
178
237
|
if (ctx.presupuesto <= 0n) return null;
|
|
179
238
|
|
|
180
|
-
const decision = await decidirDelegacion(brief,
|
|
239
|
+
const decision = await decidirDelegacion(brief, cfg);
|
|
181
240
|
if (!decision) return null;
|
|
182
241
|
|
|
183
242
|
try {
|
|
@@ -197,44 +256,52 @@ async function pedirAyudaSiHaceFalta(
|
|
|
197
256
|
}
|
|
198
257
|
}
|
|
199
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Saca el JSON de una respuesta, aunque venga envuelto.
|
|
261
|
+
*
|
|
262
|
+
* `response_format: json_object` sólo lo entiende parte del mercado, y este
|
|
263
|
+
* agente puede correr contra cualquiera. Se pide JSON en el prompt y se busca
|
|
264
|
+
* el objeto a la vuelta: unos lo envuelven en ```json y otros le ponen una
|
|
265
|
+
* frase delante, y las dos cosas son fáciles de perdonar.
|
|
266
|
+
*/
|
|
267
|
+
function extraerJson(crudo: string): unknown {
|
|
268
|
+
const sinValla = crudo.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
269
|
+
const ini = sinValla.indexOf('{');
|
|
270
|
+
const fin = sinValla.lastIndexOf('}');
|
|
271
|
+
if (ini === -1 || fin <= ini) return null;
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(sinValla.slice(ini, fin + 1));
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
200
279
|
/** Una llamada corta al modelo: ¿delego, y a quién? Formato JSON o nada. */
|
|
201
280
|
async function decidirDelegacion(
|
|
202
281
|
brief: string,
|
|
203
|
-
|
|
282
|
+
cfg: LlmConfig,
|
|
204
283
|
): Promise<{ skill: string; pregunta: string } | null> {
|
|
205
284
|
try {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
{
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
'Paying costs real money. If in doubt, do not delegate.',
|
|
227
|
-
},
|
|
228
|
-
{ role: 'user', content: brief },
|
|
229
|
-
],
|
|
230
|
-
}),
|
|
231
|
-
});
|
|
232
|
-
if (!res.ok) return null;
|
|
233
|
-
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
234
|
-
const crudo = data.choices?.[0]?.message?.content;
|
|
235
|
-
if (!crudo) return null;
|
|
236
|
-
const parsed = JSON.parse(crudo) as { delegate?: boolean; skill?: string; question?: string };
|
|
237
|
-
if (parsed.delegate !== true) return null;
|
|
285
|
+
const crudo = await llmChat(
|
|
286
|
+
// Corto a propósito, y sin reintentos: si decidir tarda más que trabajar
|
|
287
|
+
// —o cuesta más—, no compensa. Ante la duda se trabaja solo.
|
|
288
|
+
{ ...cfg, timeoutMs: 30_000, maxRetries: 0, maxTokens: 400 },
|
|
289
|
+
{
|
|
290
|
+
system:
|
|
291
|
+
'You are an agent deciding whether to PAY another specialist agent out of your own earnings ' +
|
|
292
|
+
'to do part of a job. Answer with JSON only, no prose and no code fences.\n' +
|
|
293
|
+
'{"delegate": false} if you can do the job yourself. This is the right answer almost always.\n' +
|
|
294
|
+
'{"delegate": true, "skill": "…", "question": "…"} ONLY if the job clearly needs expertise ' +
|
|
295
|
+
'outside your own, and a specialist answer would measurably improve the result.\n' +
|
|
296
|
+
'"skill" is one or two words to search a marketplace by (e.g. "translation", "legal", "json").\n' +
|
|
297
|
+
'"question" is the self-contained question for that specialist: it will be sent on its own, ' +
|
|
298
|
+
'so it must make sense without the rest of the job.\n' +
|
|
299
|
+
'Paying costs real money. If in doubt, do not delegate.',
|
|
300
|
+
user: brief,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
const parsed = extraerJson(crudo) as { delegate?: boolean; skill?: string; question?: string } | null;
|
|
304
|
+
if (!parsed || parsed.delegate !== true) return null;
|
|
238
305
|
const skill = parsed.skill?.trim();
|
|
239
306
|
const pregunta = parsed.question?.trim();
|
|
240
307
|
if (!skill || !pregunta) return null;
|
|
@@ -314,71 +381,91 @@ function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskRe
|
|
|
314
381
|
|
|
315
382
|
async function pedirAlModelo(
|
|
316
383
|
brief: string,
|
|
317
|
-
|
|
384
|
+
cfg: LlmConfig,
|
|
318
385
|
queja: string | null,
|
|
319
386
|
ayuda: string | null,
|
|
387
|
+
imagenes: { mime: string; bytes: Uint8Array }[],
|
|
388
|
+
adjuntos: AdjuntoRecibido[],
|
|
389
|
+
historial: Turno[],
|
|
320
390
|
): Promise<string> {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
391
|
+
// Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
|
|
392
|
+
// dialectos aceptan varios turnos, pero cada uno los cuenta a su manera, y
|
|
393
|
+
// lo que aquí importa no es de quién es cada mensaje: es que el modelo no
|
|
394
|
+
// confunda el material de apoyo con el encargo.
|
|
395
|
+
const partes: string[] = [];
|
|
396
|
+
|
|
397
|
+
// Lo hablado antes va PRIMERO y marcado como tal. Sin la etiqueta, el modelo
|
|
398
|
+
// confunde una pregunta vieja con la de ahora y contesta a la equivocada;
|
|
399
|
+
// con ella entiende que es contexto y que lo último es lo que se le pide.
|
|
400
|
+
const antes = historialComoTexto(historial);
|
|
401
|
+
if (antes) {
|
|
402
|
+
partes.push(`Conversación anterior con este cliente, para que sepas de qué habláis:\n\n${antes}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
partes.push(historial.length > 0 ? `Ahora te pide:\n${brief}` : brief);
|
|
406
|
+
|
|
407
|
+
// Los adjuntos que el modelo NO puede mirar. Se nombran para que sepa que
|
|
408
|
+
// existen: sin esto contesta como si el cliente no hubiera mandado nada, y
|
|
409
|
+
// el cliente ve una respuesta que ignora la mitad de lo que pidió.
|
|
410
|
+
const noMirables = adjuntos.filter((a) => !imagenes.some((i) => i.bytes === a.bytes));
|
|
411
|
+
if (noMirables.length > 0) {
|
|
412
|
+
partes.push(
|
|
413
|
+
`[El cliente adjuntó estos archivos, que no puedes abrir: ${noMirables
|
|
414
|
+
.map((a) => `${a.name}${a.mime ? ` (${a.mime})` : ''}`)
|
|
415
|
+
.join(', ')}. El agente los tiene y los procesa aparte.]`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Lo que contestó el especialista, si se le preguntó. Va marcado como
|
|
420
|
+
// material de apoyo y no como parte del encargo: sin esa aclaración el
|
|
421
|
+
// modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
|
|
422
|
+
if (ayuda) {
|
|
423
|
+
partes.push(
|
|
424
|
+
'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
|
|
425
|
+
`no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// La corrección va como una parte más: decirle QUÉ falló acierta mucho más
|
|
430
|
+
// que repetirle la misma petición a ciegas esperando otra suerte.
|
|
431
|
+
if (queja) partes.push(`Tu respuesta anterior no vale: ${queja}. Corrígela.`);
|
|
432
|
+
|
|
433
|
+
return llmChat(
|
|
324
434
|
// Sin este tope, un modelo que se cuelga deja la tarea colgada para
|
|
325
435
|
// siempre: el cliente ni cobra el resultado ni recupera su dinero hasta
|
|
326
436
|
// que vence el plazo. Pasó de verdad, en mainnet.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
|
|
362
|
-
...(ayuda
|
|
363
|
-
? [
|
|
364
|
-
{
|
|
365
|
-
role: 'user' as const,
|
|
366
|
-
content:
|
|
367
|
-
'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
|
|
368
|
-
`no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
|
|
369
|
-
},
|
|
370
|
-
]
|
|
371
|
-
: []),
|
|
372
|
-
// La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
|
|
373
|
-
// más que repetirle la misma petición a ciegas esperando otra suerte.
|
|
374
|
-
...(queja ? [{ role: 'user' as const, content: `Tu respuesta anterior no vale: ${queja}. Corrígela.` }] : []),
|
|
375
|
-
],
|
|
376
|
-
}),
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
if (!res.ok) throw new Error(`El modelo respondió ${res.status}`);
|
|
380
|
-
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
381
|
-
const text = data.choices?.[0]?.message?.content?.trim();
|
|
382
|
-
if (!text) throw new Error('El modelo devolvió una respuesta vacía.');
|
|
383
|
-
return text;
|
|
437
|
+
{ ...cfg, timeoutMs: cfg.timeoutMs ?? 120_000 },
|
|
438
|
+
{
|
|
439
|
+
system:
|
|
440
|
+
// Estas cuatro reglas cuestan de acertar a mano y se pagan caras:
|
|
441
|
+
// 1. Sin la del idioma, el modelo contesta en el suyo aunque el
|
|
442
|
+
// cliente escriba en otro, y el cliente recibe algo inservible.
|
|
443
|
+
// 2. Sin la del formato, entrega Markdown y el cliente ve `**esto**`
|
|
444
|
+
// en crudo, porque ni el dashboard ni Telegram lo renderizan.
|
|
445
|
+
// 3. Sin la del registro, envuelve el trabajo en "¡Claro! Aquí
|
|
446
|
+
// tienes…" y el entregable parece un chat, no un producto.
|
|
447
|
+
'You are a professional agent on the Panal marketplace. ' +
|
|
448
|
+
// El "never fall back to English" y lo de los títulos no son
|
|
449
|
+
// adorno: en producción, una petición en portugués volvió entera en
|
|
450
|
+
// inglés porque el prompt listaba los títulos de sección en inglés y
|
|
451
|
+
// el modelo los copiaba; y otra en chino devolvió las claves en
|
|
452
|
+
// inglés. Los dos fallos con la regla del idioma ya puesta.
|
|
453
|
+
'RULE 1, before anything else: detect the language of the request and reply in that exact same ' +
|
|
454
|
+
'language; never switch part-way, and never fall back to English because the request is not in ' +
|
|
455
|
+
'English. If the instructions below name sections, headings or field names, translate those too: ' +
|
|
456
|
+
'they are written in one language only because these instructions are. ' +
|
|
457
|
+
'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
|
|
458
|
+
'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
|
|
459
|
+
// El agente adjunta el archivo por su cuenta; el modelo no se entera
|
|
460
|
+
// y, sin esta regla, se disculpa por no poder generarlo. Ojo al
|
|
461
|
+
// matiz: prohibirle hablar de archivos A SECAS le hacía callar
|
|
462
|
+
// también sobre la foto que le acababan de mandar.
|
|
463
|
+
'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
|
|
464
|
+
'you answer. Never mention files, PDFs or attachments you would have to produce — not even to ' +
|
|
465
|
+
'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
|
|
466
|
+
'and should refer to, because the client knows they sent them.',
|
|
467
|
+
user: partes.join('\n\n'),
|
|
468
|
+
...(imagenes.length > 0 ? { imagenes } : {}),
|
|
469
|
+
},
|
|
470
|
+
);
|
|
384
471
|
}
|
|
@@ -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
|
+
}
|