El resumen en una línea: el problema casi nunca fue el modelo ni el código de captura. Fue todo lo que rodea al modelo — un import incompleto que se tragaba un error, y un system prompt distinto según el canal de entrada que hacía que el agente eligiera la herramienta equivocada. Esta es la bitácora real, sanitizada, de un sistema de archivo de grupos sobre el agente Hermes.
1. El objetivo
Capturar automáticamente todos los mensajes de los grupos de WhatsApp donde el bot es miembro — identificados por grupo, con timestamp, autor y contenido — y después poder consultarlos por DM en lenguaje natural: "resume el canal de hoy", "dame los links", "qué dijo fulano". Suena simple. La mitad lo fue.
2. La parte que sí funcionó al primer intento: la arquitectura del archivo
El conector de WhatsApp usa Baileys (cliente no oficial de WhatsApp Web sobre WebSocket). La estrategia fue un tap: interceptar el evento messages.upsert antes del filtro de allowlist y escribir cada mensaje de grupo a disco.
El pipeline
- Tap en el bridge: funciones que resuelven el nombre real del grupo (con caché en memoria), generan un slug (
"IA LATAM | Canal general"→ia-latam-canal-general), extraen el cuerpo del mensaje (texto, caption de imagen, etc.) y detectan el tipo de media. - Formato NDJSON: una línea JSON por mensaje —
{ts, chatId, groupName, senderId, senderName, body, media, fromMe, msgId}. Append-only, append-friendly, trivial de parsear conjq. - Renderer en Python: convierte el
.ndjsondel día a un.mdlegible ([HH:MM, DD/MM] Autor: mensaje). Por defecto procesa el día anterior para correr a las 23:59 con el día completo. - Timer de systemd (no cron): unit
.timera las23:59 UTCconPersistent=true, así corre al boot si la máquina estuvo apagada.
Estructura en disco — un directorio por grupo, un archivo por día:
~/.hermes/archive/
ia-latam-canal-general/
2026-06-17.ndjson ← una línea JSON por mensaje
2026-06-17.md ← legible
3. El primer bug: el fallo silencioso (un clásico)
Primer despliegue: la captura no creaba ningún archivo. Cero. Sin errores en los logs. El culpable es uno de los bugs más frustrantes de la programación moderna:
El bridge importaba de fs solo funciones específicas (mkdirSync, readFileSync, writeFileSync) — pero no appendFileSync ni fs como módulo. El código nuevo usaba fs.mkdirSync y fs.appendFileSync → ReferenceError → y el catch(e) {} que envolvía la llamada se tragaba el error en silencio. Nada se escribía, nada se quejaba.
El fix fue mecánico: agregar appendFileSync al import, usar las funciones nombradas en vez del namespace fs., eliminar un import os duplicado (ESM en modo estricto) y usar process.env.HOME en vez de os.homedir(). La lección no lo es:
Un catch vacío alrededor de una escritura es una bomba de tiempo. Si vas a tragar errores "para no romper el flujo principal", al menos haz console.error del error tragado. El silencio te cuesta horas.
4. El problema real: lograr que el agente lea el archivo
La captura quedó perfecta — verificada leyendo el .md directo en el servidor. Pero al preguntarle al agente por WhatsApp "resume el grupo de hoy", no ejecutaba cat para leer el archivo. En su lugar:
- Usaba
session_search→ buscaba en sesiones anteriores, no en el archivo. - Usaba
search_files→ buscaba por nombre de archivo, retornaba vacío. - Citaba "de su memoria" información vieja y a veces inventada.
Lo desconcertante: por CLI funcionaba perfecto. El mismo agente, el mismo modelo, la misma máquina — hermes chat -q "resume el grupo de hoy" ejecutaba el cat correcto. Por WhatsApp, ignoraba el skill, la memoria, la personalidad y el archivo de identidad.
5. La causa raíz: dos caminos, dos cerebros
El path de WhatsApp tenía un system prompt distinto al de la CLI. Mismo modelo, contexto diferente:
- No cargaba los skills automáticamente, aunque estuvieran marcados como
always_on. - No aplicaba la personalidad por defecto del agente.
- El archivo de identidad no se inyectaba en cada turno.
- Y el detalle decisivo: la herramienta
search_filestenía literalmente escrito en su descripción "Use this instead of grep/rg/find/ls in terminal". El modelo, obediente, prefería esa herramienta sobre la terminal. El propio prompt de la tool lo desviaba delcat.
La gran lección de arquitectura: cuando un agente se comporta distinto según el canal de entrada (CLI vs. WhatsApp vs. web), casi siempre hay código que arma el system prompt y la lista de tools por separado para cada canal. Tu skill puede ser perfecto y aun así no cargarse nunca en ese path. La descripción de cada tool es prompt: una frase mal puesta ahí redirige todo el comportamiento del modelo.
6. Doce intentos de fix (la crónica honesta)
Lo que siguió fue ingeniería de verdad: hipótesis, prueba, descarte. Resumen de la cacería:
- 1 · Memoria: describir el archivo en el
MEMORY.md. ❌ No se inyecta en cada turno. - 2 · Skill imperativo: reforzar el skill con una "REGLA #1". ❌ El skill no se carga en WhatsApp.
- 3 · Cambiar de modelo a uno mejor con tools. ⚠️ Funcionó en CLI pero tardó 224 segundos; en WhatsApp igual lo ignoró. Revertido.
- 4 ·
always_onen config: ❌ No surte efecto en WhatsApp. - 5 · Personalidad por defecto: ❌ Funciona en CLI, no aplica en WhatsApp.
- 6 · Archivo de identidad + limpiar memoria contaminada: ❌ Igual.
- 7 · Toolset mínimo sin
session_search. ⚠️search_filesseguía ahí (parte del setfile). - 8 · Quitar el set
filecompleto: ⚠️ El modelo respondió "herramienta no disponible" pero no usó la terminal como fallback. - 9 · Patch en el código del prompt (inyectar la regla al inicio). ⚠️ Funciona en CLI, no en el path de WhatsApp.
- 10 · Preset de toolset: ❌ El path de WhatsApp parecía ignorar la config de toolsets por completo.
- 11 · Forzar tools en el builder: ❌ Ese path no usaba la función que parchamos.
- 12 · Reescribir la descripción de
search_files— quitar el "úsala en vez de grep/cat" y apuntar al directorio del archivo (o mejor, a usar la terminal). ⏳ Pendiente de validar con un mensaje real.
Patrón doloroso pero universal: casi todos los fix funcionaban en CLI y fallaban en WhatsApp. Eso es una flecha enorme apuntando a "hay dos rutas de código y estás parchando solo una". El bug no estaba donde mirábamos.
7. Lecciones de ingeniería de agentes IA
Más allá de este sistema puntual, esto aplica a cualquiera que esté construyendo agentes con tool-calling — que es media comunidad:
- La descripción de una tool es prompt de máxima prioridad. El modelo elige herramientas leyendo sus descripciones. Una frase como "usa esto en vez de la terminal" gobierna el comportamiento. Audita las descripciones de tus tools como auditas tu prompt principal.
- Un mismo agente puede tener varios "cerebros". Si soportas varios canales (CLI, chat, API), verifica que todos armen el system prompt y carguen skills/memoria igual. Las diferencias entre paths son donde viven los bugs raros.
- Quitar una tool no garantiza un fallback. Si le sacas
search_files, el modelo puede simplemente rendirse ("no disponible") en vez de usar la terminal. El fallback hay que instruirlo, no asumirlo. - Modelo más capaz ≠ mejor en producción. El modelo que mejor usaba tools tardaba 4 minutos por respuesta: inviable para un chat. Latencia y obediencia importan tanto como capacidad bruta.
- NDJSON + un renderer es un patrón de oro para logs de agentes: append barato, parseable con
jq, y una vista legible derivada cuando la necesitas. - Falla ruidoso, no silencioso. El
catchvacío del punto 3 costó el doble que el problema que "protegía".
8. Cómo se consulta el archivo hoy
Mientras se estabiliza el camino conversacional, el archivo ya es 100% consultable a mano — y honestamente, para muchos casos esto basta:
# Ver el día actual de un grupo
cat ~/.hermes/archive/<grupo>/$(date -u +%Y-%m-%d).md
# Buscar una palabra en todos los grupos
grep -i "ley" ~/.hermes/archive/*/*.md
# Listar todos los links del día
grep -oE 'https?://[^ ]+' ~/.hermes/archive/*/$(date -u +%Y-%m-%d).md
# Conteo de mensajes por autor
cat ~/.hermes/archive/<grupo>/<fecha>.ndjson | jq -r .senderName | sort | uniq -c | sort -rn
El renderer también puede forzarse a mano para cualquier día sin esperar al timer de las 23:59. El estado conversacional por WhatsApp sigue inestable — y eso es parte de la historia: a veces el camino directo (un grep) entrega más valor que el agente, y está bien admitirlo.
9. Pendientes
- Validar el fix #12 (descripción de la tool) con un mensaje real.
- Si no funciona: trazar el path completo desde el mensaje entrante hasta la llamada al LLM y forzar la inclusión de tools ahí.
- Archivar más metadata: reenviados, reacciones, respuestas.
- Rotación/compresión de archivos antiguos (> 30 días →
.gz) y backup externo periódico. - Un pequeño dashboard para navegar el histórico.
"En agentes de IA, el código que escribes casi nunca es el problema. El problema es el contexto que el modelo recibe — y ese contexto se arma en lugares que no estás mirando."
¿Estás construyendo agentes con tool-calling? Comparte tus propios "fallos silenciosos" con la comunidad.
Conversa en la comunidad