U-12 · pruebas · agosto de 2026
La granja de agentes: un orquestador que reparte tareas entre agentes de código
Tengo dos proyectos activos — espacio-latente (este sitio) y ai-trading-lab — y una lista de tareas más larga que las horas que les puedo dedicar. La idea de la granja de agentes es simple de enunciar y no tan simple de montar bien: un orquestador que coge una tarea, la descompone, la reparte entre agentes que corren en contenedores aislados, verifica el resultado con un segundo agente, y me avisa por Telegram. Sin que yo tenga que estar delante.
Esta bitácora cuenta cómo está montado hoy, qué decisiones se han tomado y por qué, y qué queda pendiente.
RESULT_JSON (status/summary/learnings/files_changed). Memanto consulta y escribe memoria por proyecto en cada paso.La base: un fork, no un proyecto propio
La granja está construida sobre agent-loops, de Daniel Fernández — un repo suyo que cloné hace tiempo. Antes de construir nada encima me hice la pregunta obvia: ¿lo uso solo porque ya lo tengo clonado, o de verdad aguanta el peso?
La respuesta, tras auditar el código, fue que sí aguanta: ~5.300 líneas repartidas en módulos con una responsabilidad clara cada uno (board.js, dispatcher.js, docker-runner.js, git-manager.js, el conector de Telegram), con un test por módulo. No es deuda técnica escondida — es prácticamente la misma arquitectura a la que yo habría llegado: orquestador + cola + runner de Docker + bot. El riesgo real no era la calidad del código, era heredar decisiones de diseño ajenas sin darme cuenta. Ese riesgo se gestiona sobre la marcha, no reescribiendo 5.300 líneas para llegar al mismo sitio.
Lo que ya estaba construido
Un orquestador Node.js con SQLite como “board” (tablero kanban), que:
- Descompone una tarea de alto nivel en subtareas vía LLM (
decomposer.js). - Lanza un contenedor Docker por subtarea, con un perfil de agente (
backend,frontend,qa,security,devops,sre) y sus reglas propias. - Distingue maker (implementa) de checker (
qa/security, verifica lo que hizo el maker antes de dar la tarea por cerrada). - Escribe un
BOARD.mdlegible y responde por Telegram.
Lo que le faltaba para servir a dos proyectos reales a la vez, y lo que he cerrado hoy, son tres piezas.
Pieza A — multi-board de verdad
La tabla boards existía en el esquema pero no había forma de crear uno ni de decirle a una tarea a qué board pertenece salvo por su id numérico. Ahora:
POST /api/boardscrea un board ({slug, name, description}), con 409 si el slug ya existe.GET/POST /api/tasksaceptanboard: "espacio-latente"además delboard_idnumérico — así Telegram y yo pensamos en slugs, no en ids.- En Telegram,
/new,/tasks,/statusy/taskahora exigen el slug como primer argumento, sin caer a un board por defecto en silencio. Prefiero un error explícito a que una tarea aparezca en el board equivocado sin que nadie se entere.
Pieza B — un contrato en vez de adivinar logs
Esta era la pieza que más me preocupaba de la auditoría original. El orquestador se enteraba de si un agente había terminado bien leyendo el log de texto plano del contenedor, con tres heurísticas distintas buscando frases como SESSION_COMPLETE: o Session complete!. Frágil, y sobre todo: esas frases no las pedía ningún prompt actual — la plantilla decía «escribe un iteration-summary» y el parser buscaba un marcador que nadie emitía.
Ahora el contrato es explícito. Las seis plantillas de agente (agents/templates/*.template) exigen que el último mensaje del agente termine con:
RESULT_JSON:
{"status":"success","summary":"...","learnings":["..."],"files_changed":["..."]}
Y dispatcher.js ya no adivina: busca el marcador, valida el JSON, y si no aparece o no parsea no lo esconde — emite un evento result_protocol_missing visible en el board. Un runtime mal configurado se detecta en vez de disfrazarse de éxito silencioso. learnings es, además, el campo que va a alimentar memoria persistente en la siguiente pieza.
Pieza D — agrupación visual (cosmética, pero no gratis)
El board interno mantiene el enum de estados original (triage/todo/ready/running/blocked/done/archived/gave_up) porque todo el dispatcher ya razona sobre él. Pero para mirarlo de un vistazo, cuatro columnas dicen más que ocho estados: Ready · In Progress · Ready to Verify · Done/Discarded.
Lo interesante de “Ready to Verify” es que no es un estado nuevo — es una tarea ready cuyo assignee es un checker (qa/security) y cuyos hermanos maker ya han terminado. Esa condición ya existía en el dispatcher (hasRunningSiblings, la usa para decidir si lanza el checker o lo deja esperando); lo único que hice fue exponerla como vista, sin tocar el estado real.
Pieza C — memoria persistente, y por qué no vive donde yo pensaba
Antes de esta pieza, cada tarea empezaba de cero: nada de lo aprendido en una tarea llegaba a la siguiente. La idea era sencilla — antes de generar el spec de una tarea, consultar una memoria acotada al proyecto (mismo namespace que el board slug), y al terminar, escribir de vuelta usando el campo learnings del contrato de la Pieza B. Lo interesante no fue el código (poco: queryMemories/writeMemory, fail-open, dos puntos de enganche), sino dónde vive esa memoria.
Herramienta: memanto — remember/recall/answer sobre un motor de búsqueda semántica (Moorcheh) que se puede correr en su nube gratuita o on-prem. Descarté la nube por el mismo motivo de siempre en este blog: los datos de mis proyectos saliendo de mi red por una comodidad que además no hacía falta. Miré también Mem0 — más abierto y maduro — pero su imagen oficial solo trae proveedores cloud (OpenAI/Anthropic/Gemini) para el LLM; montarlo 100% local habría exigido reconstruir su imagen Docker.
Dónde, y por qué no donde pensaba. Mi primer instinto fue el NAS (Synology DS220+, siempre encendido). Craso error: un Celeron J4025 de 2 núcleos no da para correr Ollama con un LLM local a una latencia razonable, tuviera los contenedores que tuviera parados. El sitio correcto estaba delante de mis narices: el sobremesa, que ya corre un modelo de 35B para otro proyecto (Hermes, vía llama.cpp) y tiene una RTX 3090 Ti y 64 GB de RAM de sobra. El único “pero” — no está encendido 24/7 — pesa menos de lo que parece: coincide con cuándo trabajo, y ya hay Wake-on-LAN integrado para otros flujos.
Aquí entra un principio que ya se aplicó en otro proyecto de esta casa: mantener el peso del modelo en local, sin depender de una API externa como cerebro del sistema. Ese criterio, más que el benchmark de turno, fue el que decidió la arquitectura.
Lo que costó montarlo (para cuando lo vuelva a instalar):
- La última versión publicada de memanto (0.2.12) tiene un bug de compatibilidad con la versión actual de su motor — instalado directo desde el repo en vez de PyPI.
- El motor por defecto quiere el puerto 8080, que ya usaba el servidor de Hermes — reasignado sin tocar lo que ya funcionaba.
- Permisos de volumen Docker (el contenedor corre con un uid que no es el mío).
- Ollama solo escuchaba en
127.0.0.1, invisible para un contenedor — el mismo ajuste (--host 0.0.0.0) que ya hizo falta para el propio Hermes, aplicado ahora al servicio de embeddings.
Con eso resuelto: dos agentes de memoria activos (espacio-latente, ai-trading-lab, uno por proyecto), probados de punta a punta con datos reales, y con persistencia — sobreviven a un reinicio del sobremesa sin que nadie tenga que iniciar sesión.
Un auditor de deuda como parte del ciclo, no como extra
Después de cerrar las cuatro piezas, hice a mano un barrido de código muerto sobre agent-loops — exports sin caller, tests huérfanos. Encontré unas cuantas cosas. Luego probé ponytail, un plugin de Claude Code que fuerza una disciplina de “el código que nunca se escribe no hay que mantenerlo” y trae un comando de auditoría de todo el repo (/ponytail-audit). Lo corrí sobre el mismo repo que ya había mirado a mano, y encontró cosas que a mí se me habían escapado — incluida una duplicación que yo mismo acababa de introducir esa misma tarde (dos funciones casi idénticas para la misma condición, una en dispatcher.js y otra en status-renderer.js, en vez de una sola en board.js).
Cada hallazgo se verificó antes de tocar nada — que de verdad no tuviera caller, que los tests siguieran en verde después del cambio — y solo entonces se aplicó. Sin eso, “auditoría automática” es solo una lista de sugerencias sin criterio.
La conclusión práctica: esto no es una herramienta que se prueba una vez y se archiva, es un paso que tiene sentido repetir cada vez que el código crece — así que pasa a formar parte del ciclo de trabajo de este proyecto, no de una sesión suelta. Un timer de systemd de usuario (domingos 21:00, el mismo patrón que ya usaba para la revisión diaria de ai-trading-lab) lo dispara solo, repo por repo:
Lo que sigue sin decidir, a propósito
El runtime que corre dentro de cada contenedor (iteratr + opencode, hoy) podría cambiar a Claude Code CLI más adelante. Esa decisión se ha dejado fuera a propósito: todo lo construido en esta fase —el protocolo RESULT_JSON, el multi-board, la agrupación visual— es agnóstico a qué modelo o runtime hay dentro del contenedor. Si cambia el runtime, solo hace falta que el hook de fin de sesión emita el mismo marcador.