create-panal-agent 0.9.3 → 0.11.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 +10 -10
- package/dist/index.js +4 -5
- package/package.json +1 -1
- package/template/_package.json +1 -1
- package/template/src/agent.ts +159 -101
- package/template/src/server.ts +358 -19
package/README.md
CHANGED
|
@@ -64,6 +64,24 @@ import { createPanalClient } from '@panal/sdk';
|
|
|
64
64
|
await createPanalClient({ account }).withdraw();
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
### Archivos, en las dos direcciones
|
|
68
|
+
|
|
69
|
+
Tu agente **entrega** archivos devolviendo `{ text, files }` desde `handleTask`. El motor calcula el hash de cada uno y lo cuela en el texto antes de anclarlo, así que el cliente puede demostrar que lo que se baja es exactamente lo que entregaste. Un enlace a secas no daría eso.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
return { text: 'Aquí tienes el informe.', files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }] };
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Y **recibe** los que el cliente adjunte, en `ctx.adjuntos`. Llegan verificados: el encargo anunció el hash de cada uno antes de que se pagara, así que si alguien hubiera cambiado uno por el camino no llegaría hasta tu código. Las imágenes se le pasan solas al modelo; el resto lo tienes en crudo.
|
|
76
|
+
|
|
77
|
+
La regla que gobierna la entrada: **solo se escribe lo que el encargo anunció**. El número de una tarea es público, y sin esa guarda tu agente sería un almacén gratis.
|
|
78
|
+
|
|
79
|
+
### El modelo lo eliges tú
|
|
80
|
+
|
|
81
|
+
`LLM_PROVIDER=claude | gemini | kimi | grok | glm | deepseek | groq | openai | mistral | ollama`, y su clave en `LLM_API_KEY`. Nada más. `LLM_MODEL` manda sobre el sugerido, y un proveedor que no esté en la lista solo necesita `LLM_BASE_URL`.
|
|
82
|
+
|
|
83
|
+
Para que tu agente **mire** las fotos que le mandan, el modelo tiene que ser multimodal. DeepSeek, que viene por defecto, no lo es.
|
|
84
|
+
|
|
67
85
|
## Lo que conviene tener claro
|
|
68
86
|
|
|
69
87
|
**La wallet del agente vive en un servidor.** Por eso el generador te crea una nueva en vez de pedirte una: no pongas ahí la clave de tu MetaMask personal. Dale solo lo que necesita para el gas y retira lo que cobres.
|
package/dist/i18n.js
CHANGED
|
@@ -70,7 +70,7 @@ The language is taken from --lang, then PANAL_LANG, then your system locale.`,
|
|
|
70
70
|
env: {
|
|
71
71
|
key: 'Private key of your agent\'s DEDICATED wallet.\nIt is the one that gets paid and signs deliveries. Do not use your personal\nwallet: this one lives on a server. It only needs a little MON for gas.',
|
|
72
72
|
port: 'Server port. Many hosts inject it themselves.',
|
|
73
|
-
model: 'Your model, if your agent uses one.
|
|
73
|
+
model: 'Your model, if your agent uses one. Pick a provider by name and put its key below.\nLLM_MODEL overrides the suggested one; LLM_BASE_URL is for any provider not on the list.\nTo read images a client attaches, the model has to be a multimodal one.',
|
|
74
74
|
rpc: 'Your own RPC, if the public one falls short (it caps at ~15 calls/s).',
|
|
75
75
|
data: 'Where to store delivered results.',
|
|
76
76
|
x402: 'Charge per call (optional). Leave it empty and your agent only takes escrow jobs.\nThe price is per request, in an EIP-2612 token — it cannot be MON, the scheme needs `permit`.',
|
|
@@ -165,7 +165,7 @@ El idioma sale de --lang, luego de PANAL_LANG, y si no del locale del sistema.`,
|
|
|
165
165
|
env: {
|
|
166
166
|
key: 'Clave privada de la wallet DEDICADA de tu agente.\nEs la que cobra y la que firma las entregas. No uses tu wallet personal:\nesta vive en un servidor. Solo necesita un poco de MON para el gas.',
|
|
167
167
|
port: 'Puerto del servidor. Muchos hostings lo inyectan solos.',
|
|
168
|
-
model: 'Tu modelo, si tu agente usa uno.
|
|
168
|
+
model: 'Tu modelo, si tu agente usa uno. Elige proveedor por su nombre y pon su clave abajo.\nLLM_MODEL manda sobre el sugerido; LLM_BASE_URL es para cualquiera que no esté en la lista.\nPara leer las imágenes que adjunte un cliente, el modelo tiene que ser multimodal.',
|
|
169
169
|
rpc: 'RPC propio, si el público se te queda corto (limita a ~15 llamadas/s).',
|
|
170
170
|
data: 'Dónde guardar los resultados entregados.',
|
|
171
171
|
x402: 'Cobro por llamada (opcional). Déjalo vacío y tu agente solo acepta encargos del escrow.\nEl precio es por petición, en un token EIP-2612: no puede ser MON, el esquema necesita `permit`.',
|
|
@@ -261,7 +261,7 @@ const zh = {
|
|
|
261
261
|
env: {
|
|
262
262
|
key: '你的代理【专用】钱包的私钥。\n它负责收款并为交付签名。不要使用你的个人钱包:这个私钥要放在服务器上。\n它只需要少量 MON 用于支付 gas。',
|
|
263
263
|
port: '服务端口。很多托管平台会自动注入。',
|
|
264
|
-
model: '
|
|
264
|
+
model: '你的模型(如果代理需要)。按名称选择服务商,并在下面填入其密钥。\nLLM_MODEL 优先于建议的模型;LLM_BASE_URL 用于列表之外的任何服务商。\n若要读取客户附带的图片,模型必须支持多模态。',
|
|
265
265
|
rpc: '你自己的 RPC,当公共节点不够用时(其上限约为每秒 15 次调用)。',
|
|
266
266
|
data: '交付结果的存放位置。',
|
|
267
267
|
x402: '按次收费(可选)。留空则你的代理只接受托管订单。\n价格按每次请求计算,使用支持 EIP-2612 的代币——不能是 MON,该方案依赖 `permit`。',
|
|
@@ -352,7 +352,7 @@ const hi = {
|
|
|
352
352
|
env: {
|
|
353
353
|
key: 'आपके एजेंट के लिए समर्पित (DEDICATED) वॉलेट की निजी कुंजी।\nयही भुगतान पाती है और डिलीवरी पर हस्ताक्षर करती है। अपना निजी वॉलेट न लगाएँ:\nयह कुंजी सर्वर पर रहती है। इसे गैस के लिए बस थोड़ा MON चाहिए।',
|
|
354
354
|
port: 'सर्वर पोर्ट। कई होस्टिंग इसे खुद भेजते हैं।',
|
|
355
|
-
model: 'आपका मॉडल, अगर एजेंट किसी का उपयोग करता है।
|
|
355
|
+
model: 'आपका मॉडल, अगर एजेंट किसी का उपयोग करता है। नाम से प्रदाता चुनें और नीचे उसकी कुंजी डालें।\nLLM_MODEL सुझाए गए मॉडल पर भारी पड़ता है; सूची से बाहर के किसी भी प्रदाता के लिए LLM_BASE_URL है।\nक्लाइंट द्वारा भेजी गई छवियाँ पढ़ने के लिए मॉडल मल्टीमॉडल होना चाहिए।',
|
|
356
356
|
rpc: 'अपना RPC, अगर सार्वजनिक कम पड़े (इसकी सीमा ~15 कॉल/सेकंड है)।',
|
|
357
357
|
data: 'दिए गए परिणाम कहाँ सहेजें।',
|
|
358
358
|
x402: 'प्रति कॉल शुल्क (वैकल्पिक)। खाली छोड़ें तो आपका एजेंट केवल एस्क्रो वाले काम लेगा।\nकीमत प्रति अनुरोध है, EIP-2612 टोकन में — MON नहीं चल सकता, इस योजना को `permit` चाहिए।',
|
|
@@ -447,7 +447,7 @@ const ar = {
|
|
|
447
447
|
env: {
|
|
448
448
|
key: 'المفتاح الخاص بمحفظة وكيلك المخصّصة.\nهي التي تتقاضى الأجر وتوقّع عمليات التسليم. لا تستخدم محفظتك الشخصية:\nهذا المفتاح يعيش على خادم. يكفيه قليل من MON لدفع الرسوم.',
|
|
449
449
|
port: 'منفذ الخادم. كثير من الاستضافات تضبطه تلقائيًا.',
|
|
450
|
-
model: 'نموذجك، إن كان وكيلك يستخدم واحدًا.
|
|
450
|
+
model: 'نموذجك، إن كان وكيلك يستخدم واحدًا. اختر المزوّد بالاسم وضع مفتاحه أدناه.\nمتغيّر LLM_MODEL يتقدّم على النموذج المقترح، و LLM_BASE_URL لأي مزوّد ليس في القائمة.\nلقراءة الصور التي يرفقها العميل، يجب أن يكون النموذج متعدّد الوسائط.',
|
|
451
451
|
rpc: 'عقدة RPC خاصة بك، إن لم تكفِ العامة (حدّها نحو 15 طلبًا في الثانية).',
|
|
452
452
|
data: 'مكان حفظ النتائج المُسلَّمة.',
|
|
453
453
|
x402: 'التحصيل لكل استدعاء (اختياري). اتركه فارغًا فيقبل وكيلك مهام الضمان فقط.\nالسعر لكل طلب، بعملة تدعم EIP-2612 — لا يصلح MON، فالمخطط يحتاج `permit`.',
|
|
@@ -541,7 +541,7 @@ La langue vient de --lang, puis de PANAL_LANG, puis de la locale du système.`,
|
|
|
541
541
|
env: {
|
|
542
542
|
key: 'Clé privée du portefeuille DÉDIÉ de votre agent.\nC\'est elle qui encaisse et qui signe les livraisons. N\'utilisez pas votre\nportefeuille personnel : celle-ci vit sur un serveur. Un peu de MON suffit.',
|
|
543
543
|
port: 'Port du serveur. Beaucoup d\'hébergeurs l\'injectent eux-mêmes.',
|
|
544
|
-
model: 'Votre modèle, si votre agent en utilise un.
|
|
544
|
+
model: 'Votre modèle, si votre agent en utilise un. Choisissez un fournisseur par son nom et mettez sa clé ci-dessous.\nLLM_MODEL prime sur le modèle suggéré ; LLM_BASE_URL sert pour tout fournisseur absent de la liste.\nPour lire les images jointes par un client, le modèle doit être multimodal.',
|
|
545
545
|
rpc: 'Votre propre RPC, si le public ne suffit pas (limité à ~15 appels/s).',
|
|
546
546
|
data: 'Où stocker les résultats livrés.',
|
|
547
547
|
x402: "Facturation à l'appel (facultatif). Laissez vide et votre agent ne prend que des missions sous entiercement.\nLe prix est par requête, dans un jeton EIP-2612 : pas de MON, le schéma exige `permit`.",
|
|
@@ -639,7 +639,7 @@ O idioma vem de --lang, depois de PANAL_LANG e, por fim, do locale do sistema.`,
|
|
|
639
639
|
env: {
|
|
640
640
|
key: 'Chave privada da carteira DEDICADA do seu agente.\nÉ ela que recebe e que assina as entregas. Não use a sua carteira pessoal:\nesta vive num servidor. Só precisa de um pouco de MON para o gas.',
|
|
641
641
|
port: 'Porta do servidor. Muitos alojamentos injetam-na sozinhos.',
|
|
642
|
-
model: 'O seu modelo, se o agente usar um.
|
|
642
|
+
model: 'O seu modelo, se o agente usar um. Escolha um fornecedor pelo nome e ponha a sua chave abaixo.\nLLM_MODEL manda sobre o sugerido; LLM_BASE_URL serve para qualquer fornecedor fora da lista.\nPara ler as imagens que um cliente anexe, o modelo tem de ser multimodal.',
|
|
643
643
|
rpc: 'RPC próprio, se o público não chegar (limita a ~15 chamadas/s).',
|
|
644
644
|
data: 'Onde guardar os resultados entregues.',
|
|
645
645
|
x402: 'Cobrança por chamada (opcional). Deixe vazio e o seu agente só aceita trabalhos do escrow.\nO preço é por pedido, num token EIP-2612: não pode ser MON, o esquema precisa de `permit`.',
|
|
@@ -735,7 +735,7 @@ const ru = {
|
|
|
735
735
|
env: {
|
|
736
736
|
key: 'Приватный ключ ВЫДЕЛЕННОГО кошелька вашего агента.\nИменно он получает оплату и подписывает сдачу работы. Не используйте личный\nкошелёк: этот ключ живёт на сервере. Ему нужно немного MON только на газ.',
|
|
737
737
|
port: 'Порт сервера. Многие хостинги подставляют его сами.',
|
|
738
|
-
model: 'Ваша модель, если агент её использует.
|
|
738
|
+
model: 'Ваша модель, если агент её использует. Выберите провайдера по имени и укажите его ключ ниже.\nLLM_MODEL важнее предложенной модели; LLM_BASE_URL — для любого провайдера не из списка.\nЧтобы читать изображения, приложенные клиентом, модель должна быть мультимодальной.',
|
|
739
739
|
rpc: 'Свой RPC, если публичного не хватает (лимит ~15 запросов/с).',
|
|
740
740
|
data: 'Где хранить сданные результаты.',
|
|
741
741
|
x402: 'Плата за вызов (необязательно). Оставьте пустым — агент будет брать только заказы через эскроу.\nЦена за один запрос, в токене с EIP-2612: MON не подходит, схеме нужен `permit`.',
|
|
@@ -832,7 +832,7 @@ const bn = {
|
|
|
832
832
|
env: {
|
|
833
833
|
key: 'আপনার এজেন্টের নিবেদিত (DEDICATED) ওয়ালেটের প্রাইভেট কী।\nএটিই অর্থ গ্রহণ করে এবং ডেলিভারিতে স্বাক্ষর করে। ব্যক্তিগত ওয়ালেট ব্যবহার\nকরবেন না: এটি সার্ভারে থাকে। গ্যাসের জন্য সামান্য MON হলেই চলে।',
|
|
834
834
|
port: 'সার্ভারের পোর্ট। অনেক হোস্টিং নিজেই এটি দেয়।',
|
|
835
|
-
model: 'আপনার মডেল, যদি এজেন্ট ব্যবহার করে।
|
|
835
|
+
model: 'আপনার মডেল, যদি এজেন্ট ব্যবহার করে। নাম দিয়ে প্রদানকারী বেছে নিন এবং নিচে তার কী দিন।\nLLM_MODEL প্রস্তাবিত মডেলের উপরে প্রাধান্য পায়; তালিকার বাইরের যেকোনো প্রদানকারীর জন্য LLM_BASE_URL।\nক্লায়েন্টের পাঠানো ছবি পড়তে হলে মডেলটিকে মাল্টিমোডাল হতে হবে।',
|
|
836
836
|
rpc: 'নিজস্ব RPC, যদি সর্বজনীনটি যথেষ্ট না হয় (সীমা প্রায় ১৫ কল/সেকেন্ড)।',
|
|
837
837
|
data: 'সরবরাহ করা ফলাফল কোথায় রাখা হবে।',
|
|
838
838
|
x402: 'প্রতি কলে চার্জ (ঐচ্ছিক)। ফাঁকা রাখলে আপনার এজেন্ট কেবল এসক্রো কাজ নেবে।\nদাম প্রতি অনুরোধে, EIP-2612 টোকেনে — MON চলবে না, স্কিমটির `permit` দরকার।',
|
|
@@ -926,7 +926,7 @@ const ur = {
|
|
|
926
926
|
env: {
|
|
927
927
|
key: 'آپ کے ایجنٹ کے مخصوص (DEDICATED) والیٹ کی نجی کلید۔\nیہی ادائیگی وصول کرتی ہے اور ڈیلیوری پر دستخط کرتی ہے۔ اپنا ذاتی والیٹ نہ\nاستعمال کریں: یہ کلید سرور پر رہتی ہے۔ اسے گیس کے لیے تھوڑا سا MON چاہیے۔',
|
|
928
928
|
port: 'سرور کا پورٹ۔ بہت سی ہوسٹنگ خود ہی دے دیتی ہیں۔',
|
|
929
|
-
model: 'آپ کا ماڈل، اگر ایجنٹ استعمال کرے۔
|
|
929
|
+
model: 'آپ کا ماڈل، اگر ایجنٹ استعمال کرے۔ نام سے فراہم کنندہ چنیں اور نیچے اس کی کلید ڈالیں۔\nLLM_MODEL تجویز کردہ ماڈل پر مقدم ہے؛ فہرست سے باہر کسی بھی فراہم کنندہ کے لیے LLM_BASE_URL ہے۔\nکلائنٹ کی بھیجی ہوئی تصاویر پڑھنے کے لیے ماڈل کا ملٹی موڈل ہونا ضروری ہے۔',
|
|
930
930
|
rpc: 'اپنا RPC، اگر عوامی کم پڑ جائے (حد تقریباً 15 کالز فی سیکنڈ)۔',
|
|
931
931
|
data: 'فراہم کردہ نتائج کہاں محفوظ ہوں۔',
|
|
932
932
|
x402: 'فی کال وصولی (اختیاری)۔ خالی چھوڑ دیں تو آپ کا ایجنٹ صرف ایسکرو کے کام لے گا۔\nقیمت فی درخواست ہے، EIP-2612 ٹوکن میں — MON نہیں چل سکتا، اس اسکیم کو `permit` چاہیے۔',
|
package/dist/index.js
CHANGED
|
@@ -150,12 +150,11 @@ function writeEnvExample(dest, t) {
|
|
|
150
150
|
'PORT=8787',
|
|
151
151
|
'',
|
|
152
152
|
bloque(t.env.model),
|
|
153
|
-
'#
|
|
154
|
-
'
|
|
155
|
-
'# Groq https://api.groq.com/openai/v1 llama-3.3-70b-versatile',
|
|
156
|
-
'LLM_BASE_URL=https://api.deepseek.com/v1',
|
|
153
|
+
'# claude · gemini · kimi · grok · glm · deepseek · groq · openai · mistral · ollama',
|
|
154
|
+
'LLM_PROVIDER=deepseek',
|
|
157
155
|
'LLM_API_KEY=',
|
|
158
|
-
'LLM_MODEL=
|
|
156
|
+
'# LLM_MODEL=',
|
|
157
|
+
'# LLM_BASE_URL=',
|
|
159
158
|
'',
|
|
160
159
|
bloque(t.env.rpc),
|
|
161
160
|
'RPC_URL=',
|
package/package.json
CHANGED
package/template/_package.json
CHANGED
package/template/src/agent.ts
CHANGED
|
@@ -11,9 +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
16
|
|
|
17
|
+
/** Un archivo que el CLIENTE te mandó con el encargo. */
|
|
18
|
+
export interface AdjuntoRecibido {
|
|
19
|
+
/** Nombre ya limpio, tal y como lo anunció el encargo. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Tipo MIME, si lo declaró: `image/png`, `application/pdf`… */
|
|
22
|
+
mime?: string;
|
|
23
|
+
/** El contenido. Su hash ya se comprobó contra lo que la cadena cubre. */
|
|
24
|
+
bytes: Uint8Array;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
export interface TaskContext {
|
|
18
28
|
/**
|
|
19
29
|
* El id de la tarea en el escrow, o `null` si esto es una llamada x402: ahí
|
|
@@ -27,6 +37,20 @@ export interface TaskContext {
|
|
|
27
37
|
/** Fecha límite de entrega, en segundos epoch. Cero en una llamada x402. */
|
|
28
38
|
deadline: bigint;
|
|
29
39
|
|
|
40
|
+
/**
|
|
41
|
+
* LO QUE EL CLIENTE TE ADJUNTÓ: una foto, un PDF que revisar, un CSV.
|
|
42
|
+
*
|
|
43
|
+
* Llegan verificados. El encargo anunció el hash de cada uno ANTES de que se
|
|
44
|
+
* pagara, así que estos bytes son exactamente los que la cadena cubre — si
|
|
45
|
+
* alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
|
|
46
|
+
*
|
|
47
|
+
* Las imágenes se le pasan solas al modelo, si el tuyo sabe mirarlas. El
|
|
48
|
+
* resto lo tienes aquí en crudo para hacer lo que sepas hacer con ello.
|
|
49
|
+
*
|
|
50
|
+
* Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
|
|
51
|
+
*/
|
|
52
|
+
adjuntos: AdjuntoRecibido[];
|
|
53
|
+
|
|
30
54
|
/**
|
|
31
55
|
* PREGUNTAR A OTRO AGENTE, Y PAGARLE.
|
|
32
56
|
*
|
|
@@ -115,10 +139,15 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
115
139
|
// código, consultar una API, lo que quieras. No tiene por qué usar un modelo.
|
|
116
140
|
// ──────────────────────────────────────────────────────────────────────────
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
142
|
+
// El modelo, sea cual sea. `LLM_PROVIDER=claude|kimi|grok|glm|gemini|…` o
|
|
143
|
+
// `LLM_BASE_URL` a pelo para cualquiera que no esté en la lista.
|
|
144
|
+
let cfg: LlmConfig;
|
|
145
|
+
try {
|
|
146
|
+
cfg = resolverLlm(process.env);
|
|
147
|
+
} catch (err) {
|
|
120
148
|
// Sin modelo configurado se entrega algo honesto en vez de fallar: el
|
|
121
149
|
// cliente ya pagó, y dejarlo sin nada le cuesta el plazo entero.
|
|
150
|
+
console.error(`[agente] ${etiqueta(ctx)} sin modelo: ${err instanceof Error ? err.message : err}`);
|
|
122
151
|
return (
|
|
123
152
|
`No puedo completar este encargo ahora mismo: al agente le falta configurar su modelo.\n\n` +
|
|
124
153
|
`Lo que pediste:\n${brief}\n\n` +
|
|
@@ -127,16 +156,28 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
127
156
|
);
|
|
128
157
|
}
|
|
129
158
|
|
|
159
|
+
// Lo que el cliente adjuntó y un modelo puede MIRAR. El resto de adjuntos
|
|
160
|
+
// sigue en `ctx.adjuntos` para que hagas con ellos lo que sepas hacer.
|
|
161
|
+
const imagenes = ctx.adjuntos
|
|
162
|
+
.filter((a) => esImagenSoportada(a.mime))
|
|
163
|
+
.map((a) => ({ mime: a.mime!, bytes: a.bytes }));
|
|
164
|
+
if (ctx.adjuntos.length > 0) {
|
|
165
|
+
console.log(
|
|
166
|
+
`[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${imagenes.length} para el modelo: ` +
|
|
167
|
+
ctx.adjuntos.map((a) => a.name).join(', '),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
130
171
|
// ¿Esto lo sé hacer yo, o me conviene preguntar? La decisión es del agente,
|
|
131
172
|
// no del cliente: él pidió un trabajo, no una arquitectura.
|
|
132
|
-
const ayuda = await pedirAyudaSiHaceFalta(brief, ctx,
|
|
173
|
+
const ayuda = await pedirAyudaSiHaceFalta(brief, ctx, cfg);
|
|
133
174
|
|
|
134
175
|
// Un intento, una revisión y una corrección. Un modelo falla el formato de
|
|
135
176
|
// vez en cuando, y aquí eso no es un mensaje feo en un chat: el hash de lo
|
|
136
177
|
// que entregues queda anclado en la cadena y ya no se puede rectificar.
|
|
137
178
|
let queja: string | null = null;
|
|
138
179
|
for (let intento = 1; intento <= 2; intento++) {
|
|
139
|
-
const texto = await pedirAlModelo(brief,
|
|
180
|
+
const texto = await pedirAlModelo(brief, cfg, queja, ayuda, imagenes, ctx.adjuntos);
|
|
140
181
|
const problema = revisar(brief, texto);
|
|
141
182
|
if (!problema) {
|
|
142
183
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
@@ -172,12 +213,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
172
213
|
async function pedirAyudaSiHaceFalta(
|
|
173
214
|
brief: string,
|
|
174
215
|
ctx: TaskContext,
|
|
175
|
-
|
|
216
|
+
cfg: LlmConfig,
|
|
176
217
|
): Promise<string | null> {
|
|
177
218
|
// Sin presupuesto no hay nada que decidir, y así se ahorra la llamada.
|
|
178
219
|
if (ctx.presupuesto <= 0n) return null;
|
|
179
220
|
|
|
180
|
-
const decision = await decidirDelegacion(brief,
|
|
221
|
+
const decision = await decidirDelegacion(brief, cfg);
|
|
181
222
|
if (!decision) return null;
|
|
182
223
|
|
|
183
224
|
try {
|
|
@@ -197,44 +238,52 @@ async function pedirAyudaSiHaceFalta(
|
|
|
197
238
|
}
|
|
198
239
|
}
|
|
199
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Saca el JSON de una respuesta, aunque venga envuelto.
|
|
243
|
+
*
|
|
244
|
+
* `response_format: json_object` sólo lo entiende parte del mercado, y este
|
|
245
|
+
* agente puede correr contra cualquiera. Se pide JSON en el prompt y se busca
|
|
246
|
+
* el objeto a la vuelta: unos lo envuelven en ```json y otros le ponen una
|
|
247
|
+
* frase delante, y las dos cosas son fáciles de perdonar.
|
|
248
|
+
*/
|
|
249
|
+
function extraerJson(crudo: string): unknown {
|
|
250
|
+
const sinValla = crudo.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
251
|
+
const ini = sinValla.indexOf('{');
|
|
252
|
+
const fin = sinValla.lastIndexOf('}');
|
|
253
|
+
if (ini === -1 || fin <= ini) return null;
|
|
254
|
+
try {
|
|
255
|
+
return JSON.parse(sinValla.slice(ini, fin + 1));
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
200
261
|
/** Una llamada corta al modelo: ¿delego, y a quién? Formato JSON o nada. */
|
|
201
262
|
async function decidirDelegacion(
|
|
202
263
|
brief: string,
|
|
203
|
-
|
|
264
|
+
cfg: LlmConfig,
|
|
204
265
|
): Promise<{ skill: string; pregunta: string } | null> {
|
|
205
266
|
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;
|
|
267
|
+
const crudo = await llmChat(
|
|
268
|
+
// Corto a propósito, y sin reintentos: si decidir tarda más que trabajar
|
|
269
|
+
// —o cuesta más—, no compensa. Ante la duda se trabaja solo.
|
|
270
|
+
{ ...cfg, timeoutMs: 30_000, maxRetries: 0, maxTokens: 400 },
|
|
271
|
+
{
|
|
272
|
+
system:
|
|
273
|
+
'You are an agent deciding whether to PAY another specialist agent out of your own earnings ' +
|
|
274
|
+
'to do part of a job. Answer with JSON only, no prose and no code fences.\n' +
|
|
275
|
+
'{"delegate": false} if you can do the job yourself. This is the right answer almost always.\n' +
|
|
276
|
+
'{"delegate": true, "skill": "…", "question": "…"} ONLY if the job clearly needs expertise ' +
|
|
277
|
+
'outside your own, and a specialist answer would measurably improve the result.\n' +
|
|
278
|
+
'"skill" is one or two words to search a marketplace by (e.g. "translation", "legal", "json").\n' +
|
|
279
|
+
'"question" is the self-contained question for that specialist: it will be sent on its own, ' +
|
|
280
|
+
'so it must make sense without the rest of the job.\n' +
|
|
281
|
+
'Paying costs real money. If in doubt, do not delegate.',
|
|
282
|
+
user: brief,
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
const parsed = extraerJson(crudo) as { delegate?: boolean; skill?: string; question?: string } | null;
|
|
286
|
+
if (!parsed || parsed.delegate !== true) return null;
|
|
238
287
|
const skill = parsed.skill?.trim();
|
|
239
288
|
const pregunta = parsed.question?.trim();
|
|
240
289
|
if (!skill || !pregunta) return null;
|
|
@@ -314,71 +363,80 @@ function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskRe
|
|
|
314
363
|
|
|
315
364
|
async function pedirAlModelo(
|
|
316
365
|
brief: string,
|
|
317
|
-
|
|
366
|
+
cfg: LlmConfig,
|
|
318
367
|
queja: string | null,
|
|
319
368
|
ayuda: string | null,
|
|
369
|
+
imagenes: { mime: string; bytes: Uint8Array }[],
|
|
370
|
+
adjuntos: AdjuntoRecibido[],
|
|
320
371
|
): Promise<string> {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
372
|
+
// Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
|
|
373
|
+
// dialectos aceptan varios turnos, pero cada uno los cuenta a su manera, y
|
|
374
|
+
// lo que aquí importa no es de quién es cada mensaje: es que el modelo no
|
|
375
|
+
// confunda el material de apoyo con el encargo.
|
|
376
|
+
const partes = [brief];
|
|
377
|
+
|
|
378
|
+
// Los adjuntos que el modelo NO puede mirar. Se nombran para que sepa que
|
|
379
|
+
// existen: sin esto contesta como si el cliente no hubiera mandado nada, y
|
|
380
|
+
// el cliente ve una respuesta que ignora la mitad de lo que pidió.
|
|
381
|
+
const noMirables = adjuntos.filter((a) => !imagenes.some((i) => i.bytes === a.bytes));
|
|
382
|
+
if (noMirables.length > 0) {
|
|
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
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Lo que contestó el especialista, si se le preguntó. Va marcado como
|
|
391
|
+
// material de apoyo y no como parte del encargo: sin esa aclaración el
|
|
392
|
+
// modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
|
|
393
|
+
if (ayuda) {
|
|
394
|
+
partes.push(
|
|
395
|
+
'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
|
|
396
|
+
`no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// La corrección va como una parte más: decirle QUÉ falló acierta mucho más
|
|
401
|
+
// que repetirle la misma petición a ciegas esperando otra suerte.
|
|
402
|
+
if (queja) partes.push(`Tu respuesta anterior no vale: ${queja}. Corrígela.`);
|
|
403
|
+
|
|
404
|
+
return llmChat(
|
|
324
405
|
// Sin este tope, un modelo que se cuelga deja la tarea colgada para
|
|
325
406
|
// siempre: el cliente ni cobra el resultado ni recupera su dinero hasta
|
|
326
407
|
// 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;
|
|
408
|
+
{ ...cfg, timeoutMs: cfg.timeoutMs ?? 120_000 },
|
|
409
|
+
{
|
|
410
|
+
system:
|
|
411
|
+
// Estas cuatro reglas cuestan de acertar a mano y se pagan caras:
|
|
412
|
+
// 1. Sin la del idioma, el modelo contesta en el suyo aunque el
|
|
413
|
+
// cliente escriba en otro, y el cliente recibe algo inservible.
|
|
414
|
+
// 2. Sin la del formato, entrega Markdown y el cliente ve `**esto**`
|
|
415
|
+
// en crudo, porque ni el dashboard ni Telegram lo renderizan.
|
|
416
|
+
// 3. Sin la del registro, envuelve el trabajo en "¡Claro! Aquí
|
|
417
|
+
// tienes…" y el entregable parece un chat, no un producto.
|
|
418
|
+
'You are a professional agent on the Panal marketplace. ' +
|
|
419
|
+
// El "never fall back to English" y lo de los títulos no son
|
|
420
|
+
// adorno: en producción, una petición en portugués volvió entera en
|
|
421
|
+
// inglés porque el prompt listaba los títulos de sección en inglés y
|
|
422
|
+
// el modelo los copiaba; y otra en chino devolvió las claves en
|
|
423
|
+
// inglés. Los dos fallos con la regla del idioma ya puesta.
|
|
424
|
+
'RULE 1, before anything else: detect the language of the request and reply in that exact same ' +
|
|
425
|
+
'language; never switch part-way, and never fall back to English because the request is not in ' +
|
|
426
|
+
'English. If the instructions below name sections, headings or field names, translate those too: ' +
|
|
427
|
+
'they are written in one language only because these instructions are. ' +
|
|
428
|
+
'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
|
|
429
|
+
'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
|
|
430
|
+
// El agente adjunta el archivo por su cuenta; el modelo no se entera
|
|
431
|
+
// y, sin esta regla, se disculpa por no poder generarlo. Ojo al
|
|
432
|
+
// matiz: prohibirle hablar de archivos A SECAS le hacía callar
|
|
433
|
+
// también sobre la foto que le acababan de mandar.
|
|
434
|
+
'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
|
|
435
|
+
'you answer. Never mention files, PDFs or attachments you would have to produce — not even to ' +
|
|
436
|
+
'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
|
|
437
|
+
'and should refer to, because the client knows they sent them.',
|
|
438
|
+
user: partes.join('\n\n'),
|
|
439
|
+
...(imagenes.length > 0 ? { imagenes } : {}),
|
|
440
|
+
},
|
|
441
|
+
);
|
|
384
442
|
}
|
package/template/src/server.ts
CHANGED
|
@@ -25,12 +25,16 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
|
|
|
25
25
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import {
|
|
28
|
+
MAX_FILE_BYTES,
|
|
28
29
|
appendFilesManifest,
|
|
29
30
|
assertCanServe,
|
|
30
31
|
buildQuote,
|
|
31
32
|
createPanalClient,
|
|
32
33
|
LoopDetected,
|
|
33
34
|
MAINNET_ADDRESSES,
|
|
35
|
+
monad,
|
|
36
|
+
matchAttachment,
|
|
37
|
+
parseAttachmentsManifest,
|
|
34
38
|
parseEnvelope,
|
|
35
39
|
parsePaymentHeader,
|
|
36
40
|
permitNonce,
|
|
@@ -38,6 +42,7 @@ import {
|
|
|
38
42
|
sanitizeFileName,
|
|
39
43
|
TaskStatus,
|
|
40
44
|
verifyAndSettle,
|
|
45
|
+
type AttachedFile,
|
|
41
46
|
type CallEnvelope,
|
|
42
47
|
type DeliveredFile,
|
|
43
48
|
type PermitDomain,
|
|
@@ -46,7 +51,7 @@ import { privateKeyToAccount } from 'viem/accounts';
|
|
|
46
51
|
import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
|
|
47
52
|
import type { Address } from 'viem';
|
|
48
53
|
import { handleTask } from './agent.js';
|
|
49
|
-
import type { TaskContext, TaskFile, TaskResult } from './agent.js';
|
|
54
|
+
import type { AdjuntoRecibido, TaskContext, TaskFile, TaskResult } from './agent.js';
|
|
50
55
|
import { arrancarVigilante } from './vigilante.js';
|
|
51
56
|
|
|
52
57
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
@@ -54,6 +59,21 @@ const DATA_DIR = process.env.DATA_DIR ?? './data';
|
|
|
54
59
|
/** Tope del cuerpo de una petición: sin esto, cualquiera te tumba el proceso. */
|
|
55
60
|
const MAX_BODY = 256 * 1024;
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Tope del encargo, en CARACTERES, y anunciado en `/agent.json`.
|
|
64
|
+
*
|
|
65
|
+
* Va en caracteres y no en bytes porque es lo que el cliente puede contar
|
|
66
|
+
* antes de pagar: el tope de cuerpo de arriba protege el proceso, pero nadie
|
|
67
|
+
* sabe cuántos kilobytes ocupa su texto. Sin un número publicado, un encargo
|
|
68
|
+
* demasiado largo se descubre PAGANDO —el pago queda bloqueado, el agente
|
|
69
|
+
* responde 400, y el cliente espera al plazo para recuperarlo.
|
|
70
|
+
*
|
|
71
|
+
* El límite real lo pone MAX_BODY; este número queda holgadamente por debajo
|
|
72
|
+
* (32k caracteres son unos 128 KB incluso en el peor caso de UTF-8) para que
|
|
73
|
+
* lo que se promete se cumpla siempre, y no solo con texto latino.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_BRIEF_CHARS = 32_000;
|
|
76
|
+
|
|
57
77
|
const key = process.env.AGENT_PRIVATE_KEY?.trim();
|
|
58
78
|
if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
59
79
|
console.error('Falta AGENT_PRIVATE_KEY (0x + 64 hex) en el .env. Copia .env.example y rellénalo.');
|
|
@@ -177,6 +197,14 @@ mkdirSync(DATA_DIR, { recursive: true });
|
|
|
177
197
|
const resultPath = (taskId: bigint) => join(DATA_DIR, `result-${taskId}.txt`);
|
|
178
198
|
/** Carpeta de los archivos de una tarea. Una por tarea, para no mezclarlas. */
|
|
179
199
|
const filesDir = (taskId: bigint) => join(DATA_DIR, 'files', taskId.toString());
|
|
200
|
+
/**
|
|
201
|
+
* Carpeta de lo que MANDA el cliente, separada de lo que entrega el agente.
|
|
202
|
+
*
|
|
203
|
+
* Mezclarlas sería servir por `/files/:id/:name` un archivo que subió el
|
|
204
|
+
* cliente como si fuera parte de la entrega, con su hash anclado y todo. No lo
|
|
205
|
+
* es: son las dos direcciones del mismo mecanismo y no se tocan.
|
|
206
|
+
*/
|
|
207
|
+
const inboxDir = (taskId: bigint) => join(DATA_DIR, 'inbox', taskId.toString());
|
|
180
208
|
|
|
181
209
|
function saveResult(taskId: bigint, text: string): void {
|
|
182
210
|
writeFileSync(resultPath(taskId), text, 'utf8');
|
|
@@ -255,6 +283,76 @@ function normalizarSalida(salida: TaskResult): { text: string; files: TaskFile[]
|
|
|
255
283
|
return { text: salida.text, files: salida.files ?? [] };
|
|
256
284
|
}
|
|
257
285
|
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Adjuntos: lo que el cliente manda CON el encargo
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
//
|
|
290
|
+
// El brief queda cerrado al contratar —el escrow ancla su keccak256 y más
|
|
291
|
+
// abajo se rechaza cualquier texto que no lo dé—, así que una foto no puede
|
|
292
|
+
// viajar dentro. Lo que viaja dentro es su HASH, anunciado en un bloque
|
|
293
|
+
// `[panal-attach/1]`. Los bytes suben después, por `POST /upload/:taskId`.
|
|
294
|
+
//
|
|
295
|
+
// De ahí sale la única regla que hay que recordar aquí: SÓLO SE ESCRIBE LO QUE
|
|
296
|
+
// EL ENCARGO ANUNCIÓ. Cualquier otro byte se rechaza sin llegar al disco. El
|
|
297
|
+
// número de una tarea es público, y sin esa guarda tu agente sería un almacén
|
|
298
|
+
// gratis para cualquiera que sepa contar.
|
|
299
|
+
|
|
300
|
+
const adjuntoPath = (taskId: bigint, nombre: string) => join(inboxDir(taskId), nombre);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Repasa qué adjuntos anunciados están ya en disco y cuáles faltan.
|
|
304
|
+
*
|
|
305
|
+
* El hash se comprueba AL LEER y no sólo al escribir. Entre las dos cosas hay
|
|
306
|
+
* un disco, a veces un reinicio y a veces un volumen que se vuelve a montar; y
|
|
307
|
+
* un trabajo hecho a partir de un archivo corrupto es peor que un trabajo sin
|
|
308
|
+
* hacer, porque se entrega y se ancla.
|
|
309
|
+
*/
|
|
310
|
+
function repasarAdjuntos(
|
|
311
|
+
taskId: bigint,
|
|
312
|
+
brief: string,
|
|
313
|
+
): { recibidos: AdjuntoRecibido[]; faltan: AttachedFile[] } {
|
|
314
|
+
const recibidos: AdjuntoRecibido[] = [];
|
|
315
|
+
const faltan: AttachedFile[] = [];
|
|
316
|
+
|
|
317
|
+
for (const anunciado of parseAttachmentsManifest(brief)) {
|
|
318
|
+
let bytes: Buffer;
|
|
319
|
+
try {
|
|
320
|
+
bytes = readFileSync(adjuntoPath(taskId, anunciado.name));
|
|
321
|
+
} catch {
|
|
322
|
+
faltan.push(anunciado);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!matchAttachment([anunciado], bytes, anunciado.name)) {
|
|
326
|
+
console.error(`[panal] #${taskId} el adjunto "${anunciado.name}" en disco no da su hash: se pide de nuevo`);
|
|
327
|
+
faltan.push(anunciado);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
recibidos.push({
|
|
331
|
+
name: anunciado.name,
|
|
332
|
+
...(anunciado.mime ? { mime: anunciado.mime } : {}),
|
|
333
|
+
bytes: new Uint8Array(bytes),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return { recibidos, faltan };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Escribe un adjunto ya verificado. */
|
|
340
|
+
function guardarAdjunto(taskId: bigint, nombre: string, bytes: Uint8Array): void {
|
|
341
|
+
mkdirSync(inboxDir(taskId), { recursive: true });
|
|
342
|
+
writeFileSync(adjuntoPath(taskId, nombre), bytes);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* El sobre de una tarea que espera adjuntos.
|
|
347
|
+
*
|
|
348
|
+
* Cuando el encargo viene de otro agente y trae adjuntos, entre el brief y la
|
|
349
|
+
* última subida hay un rato en el que no se puede trabajar. El sobre lleva el
|
|
350
|
+
* presupuesto y el camino de la cadena, y perderlo significaría reanudar sin
|
|
351
|
+
* ellos. En memoria a propósito: si el proceso muere, la cadena que lo trajo
|
|
352
|
+
* murió con él, y reanudar sin sobre es exactamente lo que hace el vigilante.
|
|
353
|
+
*/
|
|
354
|
+
const sobrePendiente = new Map<string, CallEnvelope>();
|
|
355
|
+
|
|
258
356
|
/** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
|
|
259
357
|
const inFlight = new Set<string>();
|
|
260
358
|
|
|
@@ -399,7 +497,13 @@ async function credencialValida(
|
|
|
399
497
|
* necesita ver en los logs.
|
|
400
498
|
*/
|
|
401
499
|
function contexto(
|
|
402
|
-
base: {
|
|
500
|
+
base: {
|
|
501
|
+
taskId: bigint | null;
|
|
502
|
+
client: string;
|
|
503
|
+
amount: bigint;
|
|
504
|
+
deadline: bigint;
|
|
505
|
+
adjuntos: AdjuntoRecibido[];
|
|
506
|
+
},
|
|
403
507
|
sobre: CallEnvelope | null,
|
|
404
508
|
): TaskContext {
|
|
405
509
|
return {
|
|
@@ -437,10 +541,36 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
|
|
|
437
541
|
// Lo PRIMERO, antes de trabajar: si el proceso muere a mitad, esto es lo
|
|
438
542
|
// único que permite retomarlo. Guardarlo después sería guardarlo nunca.
|
|
439
543
|
saveBrief(taskId, brief);
|
|
544
|
+
|
|
545
|
+
// Si el encargo anuncia adjuntos, no se empieza hasta tenerlos todos.
|
|
546
|
+
//
|
|
547
|
+
// La guarda va AQUÍ y no en la ruta HTTP porque el vigilante también llama
|
|
548
|
+
// a `work` —al retomar una tarea tras un reinicio— y ahí no hay petición
|
|
549
|
+
// que mirar. Sin esto, un agente que se reinicia entre el brief y la
|
|
550
|
+
// subida se pondría a trabajar sin la foto, entregaría lo que pudiera y
|
|
551
|
+
// anclaría ese resultado a medias en la cadena.
|
|
552
|
+
const { recibidos, faltan } = repasarAdjuntos(taskId, brief);
|
|
553
|
+
if (faltan.length > 0) {
|
|
554
|
+
console.log(
|
|
555
|
+
`[panal] #${taskId} en espera de ${faltan.length} adjunto(s): ${faltan.map((f) => f.name).join(', ')}`,
|
|
556
|
+
);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (recibidos.length > 0) console.log(`[panal] #${taskId} con ${recibidos.length} adjunto(s) del cliente`);
|
|
560
|
+
|
|
440
561
|
const task = await leerTarea(taskId);
|
|
441
562
|
const salida = await handleTask(
|
|
442
563
|
brief,
|
|
443
|
-
contexto(
|
|
564
|
+
contexto(
|
|
565
|
+
{
|
|
566
|
+
taskId,
|
|
567
|
+
client: task.client,
|
|
568
|
+
amount: task.amount,
|
|
569
|
+
deadline: task.deadline,
|
|
570
|
+
adjuntos: recibidos,
|
|
571
|
+
},
|
|
572
|
+
sobre,
|
|
573
|
+
),
|
|
444
574
|
);
|
|
445
575
|
|
|
446
576
|
// Tu handleTask puede devolver un texto a secas —lo normal— o un texto con
|
|
@@ -547,6 +677,25 @@ function json(res: ServerResponse, status: number, body: unknown): void {
|
|
|
547
677
|
res.end(JSON.stringify(body));
|
|
548
678
|
}
|
|
549
679
|
|
|
680
|
+
/**
|
|
681
|
+
* El cuerpo en crudo, con su propio tope.
|
|
682
|
+
*
|
|
683
|
+
* Va aparte de `readBody` a propósito: los 256 KB de MAX_BODY protegen las
|
|
684
|
+
* rutas de texto y tienen que seguir siendo pequeños. Una foto no cabe ahí, y
|
|
685
|
+
* subirle el tope a todas las rutas para que quepa sería abrir la puerta que
|
|
686
|
+
* ese límite cierra.
|
|
687
|
+
*/
|
|
688
|
+
async function readBodyBytes(req: IncomingMessage, max: number): Promise<Buffer> {
|
|
689
|
+
const chunks: Buffer[] = [];
|
|
690
|
+
let total = 0;
|
|
691
|
+
for await (const chunk of req) {
|
|
692
|
+
total += (chunk as Buffer).length;
|
|
693
|
+
if (total > max) throw new Error(`cuerpo demasiado grande (tope ${max} bytes)`);
|
|
694
|
+
chunks.push(chunk as Buffer);
|
|
695
|
+
}
|
|
696
|
+
return Buffer.concat(chunks);
|
|
697
|
+
}
|
|
698
|
+
|
|
550
699
|
async function readBody(req: IncomingMessage): Promise<string> {
|
|
551
700
|
const chunks: Buffer[] = [];
|
|
552
701
|
let total = 0;
|
|
@@ -728,9 +877,15 @@ const server = createServer((req, res) => {
|
|
|
728
877
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
|
|
729
878
|
// Las credenciales van en cabeceras propias, y esas NO son simples: sin
|
|
730
879
|
// declararlas aquí el navegador bloquea la descarga en el preflight.
|
|
880
|
+
//
|
|
881
|
+
// Cada cabecera nueva hay que añadirla A ESTA LISTA. Se olvidó con
|
|
882
|
+
// `x-panal-filename` al añadir los adjuntos, y el efecto es de los que
|
|
883
|
+
// no se ven leyendo el código: el servidor está bien, la ruta está bien,
|
|
884
|
+
// y el navegador se niega a hacer la petición sin dejar rastro en el log
|
|
885
|
+
// del agente.
|
|
731
886
|
res.setHeader(
|
|
732
887
|
'access-control-allow-headers',
|
|
733
|
-
'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-payment, x-payment-payer',
|
|
888
|
+
'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-panal-filename, x-payment, x-payment-payer',
|
|
734
889
|
);
|
|
735
890
|
res.setHeader('access-control-max-age', '86400');
|
|
736
891
|
res.writeHead(204).end();
|
|
@@ -743,24 +898,56 @@ const server = createServer((req, res) => {
|
|
|
743
898
|
// LexPanal tuvo x402 funcionando y nadie lo usó, sencillamente porque no
|
|
744
899
|
// salía en su tarjeta: un cobro que nadie puede descubrir no existe.
|
|
745
900
|
if (url.pathname === '/agent.json' && req.method === 'GET') {
|
|
901
|
+
const base = process.env.PUBLIC_URL?.trim().replace(/\/+$/, '') || null;
|
|
902
|
+
const x402 =
|
|
903
|
+
X402_PRICE !== null
|
|
904
|
+
? {
|
|
905
|
+
method: 'POST' as const,
|
|
906
|
+
path: '/x402/ask',
|
|
907
|
+
...(base ? { url: `${base}/x402/ask` } : {}),
|
|
908
|
+
scheme: 'eip2612-permit',
|
|
909
|
+
asset: X402_TOKEN,
|
|
910
|
+
assetSymbol: X402_SYMBOL,
|
|
911
|
+
amount: X402_PRICE.toString(),
|
|
912
|
+
payTo: account.address,
|
|
913
|
+
howTo: 'POST {"prompt":"…"} and you get a 402 with the quote. Sign it and repeat with X-Payment.',
|
|
914
|
+
}
|
|
915
|
+
: null;
|
|
916
|
+
|
|
746
917
|
json(res, 200, {
|
|
747
918
|
agent: account.address,
|
|
748
919
|
protocol: 'panal',
|
|
749
920
|
network: 'monad-mainnet',
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
921
|
+
chainId: monad.id,
|
|
922
|
+
endpoints: {
|
|
923
|
+
base,
|
|
924
|
+
postBrief: {
|
|
925
|
+
method: 'POST',
|
|
926
|
+
path: '/brief/:taskId',
|
|
927
|
+
signMessage: 'Panal brief #<taskId> (EIP-191, firmado por el cliente de la tarea)',
|
|
928
|
+
body: `{"brief": string (máx. ${MAX_BRIEF_CHARS} chars), "address": "0x…", "signature": "0x…"}`,
|
|
929
|
+
maxBriefChars: MAX_BRIEF_CHARS,
|
|
930
|
+
},
|
|
931
|
+
postAttachment: {
|
|
932
|
+
method: 'POST',
|
|
933
|
+
path: '/upload/:taskId',
|
|
934
|
+
signMessage: 'Panal brief #<taskId> (la MISMA firma que el encargo, no hace falta otra)',
|
|
935
|
+
body: 'los bytes en crudo; el nombre en la cabecera X-Panal-Filename',
|
|
936
|
+
howTo:
|
|
937
|
+
'anuncia cada adjunto en el brief con un bloque [panal-attach/1] ANTES de contratar, y sube los bytes aquí después. Sólo se aceptan los que el encargo anuncie.',
|
|
938
|
+
maxAttachmentBytes: MAX_FILE_BYTES,
|
|
939
|
+
},
|
|
940
|
+
getResult: {
|
|
941
|
+
method: 'GET',
|
|
942
|
+
path: '/result/:taskId',
|
|
943
|
+
signMessage: 'Panal resultado #<taskId> · <epoch> (EIP-191, cabeceras X-Panal-*)',
|
|
944
|
+
},
|
|
945
|
+
...(x402 ? { x402Ask: x402 } : {}),
|
|
946
|
+
},
|
|
947
|
+
// ALIAS ANTIGUO, en la raíz. Aquí es donde esta plantilla lo publicaba
|
|
948
|
+
// antes, y hay clientes ahí fuera que solo miran este sitio. Se sirve
|
|
949
|
+
// por compatibilidad y desaparecerá; lo que se lee es `endpoints`.
|
|
950
|
+
...(x402 ? { x402Ask: x402 } : {}),
|
|
764
951
|
});
|
|
765
952
|
return;
|
|
766
953
|
}
|
|
@@ -849,7 +1036,18 @@ const server = createServer((req, res) => {
|
|
|
849
1036
|
try {
|
|
850
1037
|
const salida = await handleTask(
|
|
851
1038
|
prompt,
|
|
852
|
-
contexto(
|
|
1039
|
+
contexto(
|
|
1040
|
+
{
|
|
1041
|
+
taskId: null,
|
|
1042
|
+
client: leido.payment.payer,
|
|
1043
|
+
amount: cobro.amount,
|
|
1044
|
+
deadline: 0n,
|
|
1045
|
+
// Una llamada x402 es una pregunta y una respuesta: no hay tarea
|
|
1046
|
+
// donde anclar un adjunto, así que tampoco hay adjuntos.
|
|
1047
|
+
adjuntos: [],
|
|
1048
|
+
},
|
|
1049
|
+
sobre,
|
|
1050
|
+
),
|
|
853
1051
|
);
|
|
854
1052
|
// En una llamada x402 no hay tarea, así que no hay nada que anclar ni
|
|
855
1053
|
// ninguna firma con la que proteger una descarga: los archivos no
|
|
@@ -902,6 +1100,16 @@ const server = createServer((req, res) => {
|
|
|
902
1100
|
json(res, 400, { error: 'faltan taskId, brief o signature' });
|
|
903
1101
|
return;
|
|
904
1102
|
}
|
|
1103
|
+
// El tope que anuncia /agent.json, aplicado. Se dice el número en la
|
|
1104
|
+
// respuesta: el cliente ya pagó, y saber cuánto recortar es la
|
|
1105
|
+
// diferencia entre reenviarlo y perder el encargo.
|
|
1106
|
+
if (body.brief.length > MAX_BRIEF_CHARS) {
|
|
1107
|
+
json(res, 400, {
|
|
1108
|
+
error: `el encargo son ${body.brief.length} caracteres y el tope es ${MAX_BRIEF_CHARS}`,
|
|
1109
|
+
maxBriefChars: MAX_BRIEF_CHARS,
|
|
1110
|
+
});
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
905
1113
|
const taskId = BigInt(idCrudo);
|
|
906
1114
|
|
|
907
1115
|
// El sobre de la cadena, si este encargo viene de otro agente. Se mira
|
|
@@ -957,12 +1165,143 @@ const server = createServer((req, res) => {
|
|
|
957
1165
|
return;
|
|
958
1166
|
}
|
|
959
1167
|
|
|
1168
|
+
// El encargo se guarda YA, antes de contestar: la subida que viene
|
|
1169
|
+
// detrás lo necesita en disco para saber qué bytes puede aceptar.
|
|
1170
|
+
saveBrief(taskId, body.brief);
|
|
1171
|
+
const { faltan } = repasarAdjuntos(taskId, body.brief);
|
|
1172
|
+
if (faltan.length > 0) {
|
|
1173
|
+
// No es un error: es la otra mitad del encargo, que aún viene de
|
|
1174
|
+
// camino. Se contesta exactamente qué se espera para que el cliente lo
|
|
1175
|
+
// suba sin tener que adivinarlo.
|
|
1176
|
+
if (sobre) sobrePendiente.set(taskId.toString(), sobre);
|
|
1177
|
+
json(res, 202, {
|
|
1178
|
+
ok: true,
|
|
1179
|
+
faltanAdjuntos: faltan.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
|
|
1180
|
+
subirA: `/upload/${taskId}`,
|
|
1181
|
+
});
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
960
1185
|
json(res, 202, { ok: true });
|
|
961
1186
|
// Sin await: el cliente no debería esperar a que termines de trabajar.
|
|
962
1187
|
void work(taskId, body.brief, sobre);
|
|
963
1188
|
return;
|
|
964
1189
|
}
|
|
965
1190
|
|
|
1191
|
+
// ---- El cliente sube los adjuntos que su encargo anunció ----------------
|
|
1192
|
+
//
|
|
1193
|
+
// Se firma UNA vez, con el mismo `Panal brief #<id>` que abrió el encargo.
|
|
1194
|
+
// Pedir una firma por archivo sería pedirle tres popups a alguien que ya
|
|
1195
|
+
// pagó, y no compraría nada: lo que decide qué entra no es la firma, es el
|
|
1196
|
+
// manifiesto que la cadena ya cubre.
|
|
1197
|
+
const subida = /^\/upload\/(\d+)$/.exec(url.pathname);
|
|
1198
|
+
if (subida && req.method === 'POST') {
|
|
1199
|
+
const taskId = BigInt(subida[1]!);
|
|
1200
|
+
/** Rechaza vaciando el cuerpo: si no, el cliente ve un reset en vez del motivo. */
|
|
1201
|
+
const rechazar = (status: number, cuerpo: unknown): void => {
|
|
1202
|
+
req.resume();
|
|
1203
|
+
json(res, status, cuerpo);
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
// Lo local primero, que no cuesta ni RPC ni ancho de banda.
|
|
1207
|
+
const brief = loadBrief(taskId);
|
|
1208
|
+
if (!brief) {
|
|
1209
|
+
rechazar(409, { error: 'manda antes el encargo a POST /brief/' + taskId });
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const anunciados = parseAttachmentsManifest(brief);
|
|
1213
|
+
if (anunciados.length === 0) {
|
|
1214
|
+
rechazar(409, { error: 'ese encargo no anuncia ningún adjunto' });
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const cred = credencialesDe(req, url);
|
|
1219
|
+
if (!cred.address || !cred.signature) {
|
|
1220
|
+
rechazar(400, { error: 'faltan address y signature (cabeceras x-panal-address / x-panal-signature)' });
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const task = await leerTarea(taskId);
|
|
1225
|
+
if (task.worker.toLowerCase() !== account.address.toLowerCase()) {
|
|
1226
|
+
rechazar(403, { error: 'esa tarea no es de este agente' });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (task.status !== TaskStatus.Open) {
|
|
1230
|
+
rechazar(409, { error: `la tarea está ${TaskStatus[task.status]}` });
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (cred.address.toLowerCase() !== task.client.toLowerCase()) {
|
|
1234
|
+
rechazar(403, { error: 'solo el cliente de la tarea puede subirle adjuntos' });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
if (!(await signedBy(briefSignMessage(taskId), cred.signature, task.client))) {
|
|
1238
|
+
rechazar(401, { error: 'la firma no es del cliente de esta tarea' });
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Nada puede pesar más que el mayor de los adjuntos anunciados: el
|
|
1243
|
+
// tamaño va DENTRO del manifiesto, o sea dentro de lo que la cadena
|
|
1244
|
+
// cubre. Se mira antes de leer para no tragarse los bytes de nadie.
|
|
1245
|
+
const tope = Math.min(MAX_FILE_BYTES, Math.max(...anunciados.map((f) => f.size)));
|
|
1246
|
+
const declarado = Number(req.headers['content-length'] ?? 0);
|
|
1247
|
+
if (declarado > tope) {
|
|
1248
|
+
rechazar(413, { error: `ese archivo son ${declarado} bytes y el mayor que anunciaste mide ${tope}` });
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
let bytes: Buffer;
|
|
1253
|
+
try {
|
|
1254
|
+
bytes = await readBodyBytes(req, tope);
|
|
1255
|
+
} catch (err) {
|
|
1256
|
+
json(res, 413, { error: err instanceof Error ? err.message : 'cuerpo demasiado grande' });
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// La guarda. Se busca por hash, así que el nombre que venga en la
|
|
1261
|
+
// cabecera no decide nada: sólo desempata si el mismo archivo se
|
|
1262
|
+
// adjuntó dos veces.
|
|
1263
|
+
// El nombre viene percent-encoded: una cabecera HTTP no admite
|
|
1264
|
+
// caracteres fuera de latin-1, y «recibo ñ.png» es un nombre normal.
|
|
1265
|
+
let nombre: string | undefined;
|
|
1266
|
+
const cabecera = req.headers['x-panal-filename'];
|
|
1267
|
+
if (typeof cabecera === 'string') {
|
|
1268
|
+
try {
|
|
1269
|
+
nombre = decodeURIComponent(cabecera);
|
|
1270
|
+
} catch {
|
|
1271
|
+
nombre = cabecera;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
const anunciado = matchAttachment(anunciados, bytes, nombre);
|
|
1275
|
+
if (!anunciado) {
|
|
1276
|
+
json(res, 403, {
|
|
1277
|
+
error: 'esos bytes no son ninguno de los adjuntos que anuncia el encargo',
|
|
1278
|
+
esperados: anunciados.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
|
|
1279
|
+
});
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
guardarAdjunto(taskId, anunciado.name, bytes);
|
|
1284
|
+
const { faltan: pendientes } = repasarAdjuntos(taskId, brief);
|
|
1285
|
+
console.log(
|
|
1286
|
+
`[panal] #${taskId} adjunto "${anunciado.name}" recibido (${bytes.byteLength} bytes) · faltan ${pendientes.length}`,
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
json(res, 202, {
|
|
1290
|
+
ok: true,
|
|
1291
|
+
guardado: anunciado.name,
|
|
1292
|
+
faltanAdjuntos: pendientes.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// Con el último adjunto ya se puede trabajar. El encargo estaba en
|
|
1296
|
+
// espera desde que llegó; esto es lo que lo suelta.
|
|
1297
|
+
if (pendientes.length === 0) {
|
|
1298
|
+
const sobreGuardado = sobrePendiente.get(taskId.toString()) ?? null;
|
|
1299
|
+
sobrePendiente.delete(taskId.toString());
|
|
1300
|
+
void work(taskId, brief, sobreGuardado);
|
|
1301
|
+
}
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
966
1305
|
// ---- El cliente recoge su resultado -------------------------------------
|
|
967
1306
|
const match = /^\/result\/(\d+)$/.exec(url.pathname);
|
|
968
1307
|
if (match && req.method === 'GET') {
|