Ajustando un Agente al Estilo Hermes que Crece con tu Proyecto
Un asistente de IA específico para cada proyecto construido sobre un canal de mensajería —Telegram, Discord, Slack o email a través de una gateway ligera— se comporta de manera diferente en un grupo con múltiples usuarios que en un DM individual. Incorporar un equipo al bot expone una clase de bugs que el camino de prueba por DM nunca alcanza: crashes por respuestas vacías en triggers de palabras clave, notificaciones de progreso mal calibradas, race conditions entre triggers paralelos y deriva de memoria en el log de conversación del propio agente.
Esta guía documenta ocho patrones de ajuste que aplicamos a un asistente mínimo al estilo hermes —construido sobre la CLI de Claude Code como subprocess en lugar del framework completo de NousResearch. Cada patrón es un problema concreto con el código para solucionarlo. Los patrones aplican a cualquiera de los dos enfoques.
Qué soluciona esto
- Respuestas genéricas de
Unexpected errorcuando el LLM devuelve stdout vacío ante un trigger de palabra clave - Texto de confirmación que se dispara en cada respuesta corta porque el umbral está por debajo de la latencia típica de respuesta
- Subprocesses que se detienen a mitad del stream y nunca retornan, sin timeout para limpiarlos
- Race conditions cuando dos miembros del grupo activan el agente simultáneamente contra una sesión compartida
- Deadlocks silenciosos en la pipe de stderr una vez que el agente lleva suficiente tiempo en ejecución para llenar el buffer de 64 KB
- Mensajes de fallback por bug en el log de conversación que el agente lee después como comportamiento legítimo
Prerrequisitos
Esta guía asume la arquitectura de Construyendo un Asistente de IA Específico para Proyectos vía Telegram: un container Docker por proyecto, usuario sin privilegios de root, CLI de Claude Code con autenticación OAuth persistente, wrapper de bot con aiogram, y volúmenes de workspace y estado de sesión montados con bind. Varios patrones dependen de versiones específicas:
- CLI de Claude Code 2.1.139 o posterior (para
--output-format stream-json --verbose --include-partial-messages) - aiogram 3.28.2 o posterior (para
ChatActionSender,message.react(),ReactionTypeEmoji) - Imagen base de Python 3.13
- Un grupo de Telegram en el que el bot sea miembro con
can_react_to_messages: true
Para un canal de email en el mismo proyecto, consulta cómo configurar un buzón de proyecto con DKIM, SPF y DMARC.
Qué es un agente al estilo Hermes
El patrón hermes-style toma su nombre del framework abierto de NousResearch. Tres propiedades lo distinguen de un chatbot sin estado:
- Memoria persistente. Un workspace en disco que el agente lee y escribe entre turnos, para que el contexto sobreviva a reinicios del container.
- Presencia multicanal. La misma instancia del agente conversa en Telegram, Discord, Slack o email a través de una gateway ligera.
- Un ciclo de aprendizaje cerrado. Las correcciones del operador se convierten en ediciones del workspace que el agente lee en el siguiente turno.
NousResearch incluye una implementación de referencia completa con TUI, gateway multicanal, sistema de habilidades y hooks de entrenamiento RL. Una variante mínima sobre el subprocess de la CLI de Claude Code mantiene las piezas móviles lo suficientemente pequeñas como para plantillarlas por mandato de consultoría. Los patrones a continuación aplican igualmente a cualquiera de los dos enfoques.
Patrón 1: Manejo tipado de respuestas vacías
Un trigger basado en palabras clave (que hace match con \bhermes\b en mensajes de grupo) puede activarse en una frase que contiene el nombre del bot pero no va dirigida a él. El LLM devuelve correctamente una salida vacía. Tres capas más abajo, ninguna maneja el caso vacío:
- El motor devuelve
""con returncode 0. - La función de split devuelve
[""]porquelen("") <= max_charscoincide. - El loop de envío llama a
bot.send_message(chat_id, ""); Telegram devuelveBad Request: message text is empty; unexcept Exceptiongenérico en lo alto del handler traga el traceback y envía al usuario el mensaje de error.
Filtrar strings vacíos en una sola capa evita el crash, pero produce un salto silencioso: el trigger se disparó, el bot consumió cómputo, y el usuario no ve nada. La solución en dos pasos usa una excepción tipada para la salida vacía y una reacción de Telegram (👀) sobre el mensaje que activó el trigger como acuse de recibo:
class HermesEmptyResponse(HermesError):
"""Subprocess returned successfully but with empty result."""
class HermesHangError(HermesError):
"""Watchdog killed subprocess after no stream-event for N seconds."""
El motor lanza HermesEmptyResponse cuando result.strip() == "". El handler lo captura y llama a message.react([ReactionTypeEmoji(emoji="👀")]). El log de conversación recibe un bloque marcador —una entrada separada que registra el acuse de recibo silencioso sin contaminar el chat con texto— para que las lecturas futuras de memoria del propio agente vean que un trigger se disparó y fue ignorado intencionalmente.
Patrón 2: Verificación previa de permisos de reacción con caché diferida
El setMessageReaction de Telegram no está disponible de manera universal. Algunos grupos restringen el conjunto de reacciones permitidas; algunos emojis personalizados requieren autorización de un administrador. El tipo ChatFullInfo documenta la regla: si available_reactions se omite, se permiten todos los emoji estándar; si es un array, solo funcionan esos emoji. El bot debe ser miembro del grupo —no se requiere ser administrador para reaccionar en grupos.
Verificar en cada trigger desperdicia llamadas a la API. Basta con un getChat por chat con una caché de una hora:
_reaction_cache: dict[int, tuple[bool, float]] = {}
_REACTION_CACHE_TTL_SEC = 3600
MINI_ACK_EMOJI = "👀"
async def _reactions_allowed(bot: Bot, chat_id: int) -> bool:
now = time.monotonic()
cached = _reaction_cache.get(chat_id)
if cached and cached[1] > now:
return cached[0]
try:
chat = await bot.get_chat(chat_id)
allowed = (
chat.available_reactions is None
or any(
isinstance(r, ReactionTypeEmoji) and r.emoji == MINI_ACK_EMOJI
for r in (chat.available_reactions or [])
)
)
except Exception:
allowed = False
_reaction_cache[chat_id] = (allowed, now + _REACTION_CACHE_TTL_SEC)
return allowed
Envuelve la llamada real de reacción en try/except (TelegramBadRequest, TelegramForbiddenError) de todas formas —la caché se retrasa respecto a los cambios de permisos.
Patrón 3: Modo stream y el watchdog de tiempo de inactividad
Un timeout duro sobre todo el subprocess (asyncio.wait_for(proc.communicate(), timeout=300)) limita la duración total independientemente del progreso. Eliminarlo sin reemplazo está documentado como inseguro: el issue de cuelgues en stream inactivo de Claude Code describe llamadas a la API que se detienen a mitad del stream y nunca retornan, dejando un subprocess fugado.
Cambiar a --output-format stream-json --verbose --include-partial-messages emite eventos en cada hito —por token text_delta, inicio y fin de uso de herramientas, reintentos de API, avisos de rate limit, el evento final result. Un cuelgue real produce silencio en el stream; una tarea larga produce una secuencia de eventos pequeños. El watchdog mata por tiempo de inactividad, no por duración total:
WATCHDOG_NO_EVENT_SEC = 60
async def watchdog() -> None:
while True:
await asyncio.sleep(5)
if proc.returncode is not None:
return
idle_sec = time.monotonic() - state["last_event_ts"]
if idle_sec > WATCHDOG_NO_EVENT_SEC:
state["killed_by_watchdog"] = True
try:
proc.kill()
except ProcessLookupError:
pass
return
El texto de respuesta final proviene del campo result del evento result —determinista, de una única fuente y no afectado por el parseo parcial del stream. El mismo evento lleva is_error, api_error_status, duration_ms y total_cost_usd, todos los cuales van a la línea del log estructurado.
Patrón 4: Calibrando el calendario de mensajes de espera
La pregunta sobre el umbral —cuándo envía el bot una actualización de texto durante una llamada de larga duración— es empírica. La respuesta correcta depende de la distribución de latencia de los triggers reales. Tres umbrales, con texto alineado a lo que el usuario realmente necesita saber:
_REASSURE_SCHEDULE = (
(15, "On it."),
(90, "Taking longer than usual, still on it."),
(300, "Genuinely large task — almost there."),
)
Los umbrales derivan de dos restricciones. El límite inferior lo establece la latencia típica de respuesta corta: si la mayoría de las respuestas llegan dentro de X segundos, el primer mensaje de espera debe dispararse después de X, o llegará prácticamente al mismo tiempo que la respuesta. La investigación sobre tiempos de respuesta de Nielsen identifica 10 segundos como el límite canónico para mantener la atención del usuario sin un indicador de progreso; el indicador de escritura que renderiza el ChatActionSender de aiogram por debajo de ese umbral ya satisface el requisito hasta aproximadamente 15 segundos.
El umbral superior (90 segundos) es la brecha en la que el encuadre cambia de trabajando a trabajando pero más de lo habitual —una señal separada de que la llamada está en la cola larga de la distribución. La redacción evita insinuar que el usuario pidió algo pesado. El bot es quien hace el trabajo; el mensaje reconoce ese trabajo, no la solicitud.
Patrón 5: Lock de concurrencia por chat
Dos miembros del grupo pueden activar el agente en el mismo segundo —uno con una mención @, otro con la palabra clave. Ambas invocaciones del handler inician subprocesses de claude --continue contra el mismo archivo de sesión persistente. El lock-file de sesión no es estricto; las escrituras concurrentes producen archivos session-jsonl truncados y turnos perdidos.
Serializa por chat a nivel del handler con un lock creado de forma diferida:
_chat_locks: dict[int, asyncio.Lock] = {}
def _get_chat_lock(chat_id: int) -> asyncio.Lock:
lock = _chat_locks.get(chat_id)
if lock is None:
lock = asyncio.Lock()
_chat_locks[chat_id] = lock
return lock
async with _get_chat_lock(message.chat.id):
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
La creación diferida es importante: un asyncio.Lock instanciado al importar el módulo se enlaza al event loop que esté activo en ese momento, que puede no ser el loop en el que corre el handler tras un reinicio. Diferir la instanciación hasta la primera llamada dentro de un loop activo evita el desajuste de enlace. Para grupos pequeños, el diccionario de locks permanece pequeño; para flotas más grandes, añade evicción LRU.
Patrón 6: Jerarquía de excepciones y orden de los except
Las clases de excepción del motor forman un árbol:
HermesError(RuntimeError)— cualquier problema con el subprocessHermesEmptyResponse(HermesError)— ejecución exitosa con resultado vacíoHermesHangError(HermesError)— watchdog mató el proceso
El except de Python hace match con la primera cláusula compatible. Si except HermesError precede a los handlers de subclases, captura HermesEmptyResponse y lo enruta al camino de error, saltándose el mini-acuse de recibo. El orden subclase-primero es obligatorio:
try:
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
except HermesEmptyResponse:
# camino del mini-acuse de recibo
...
except HermesHangError as exc:
# camino de reintentar-una-vez-y-desistir
...
except HermesError as exc:
# exit distinto de cero, api-error, etc.
...
except Exception:
# último recurso
...
Añade esto a una checklist de revisión de código. Reordenar los bloques visualmente invierte la intención.
Patrón 7: Drenando stderr en paralelo
Hacer streaming por stdout requiere leer líneas a medida que llegan: async for line in proc.stdout. Si stderr también está en pipe, el subprocess puede llenar su buffer de stderr mientras stdout sigue siendo leído. Los buffers de pipe por defecto en Linux son de unos 64 KB. Una vez que stderr se llena, el subprocess se bloquea esperando que se drene, y el loop async-for-line nunca avanza. El watchdog eventualmente lo mata tras el período de inactividad, pero el resultado se pierde.
Drena stderr en paralelo desde el inicio del subprocess, luego espera la tarea de drenado después de proc.wait():
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(WORKSPACE),
)
stderr_task = asyncio.create_task(proc.stderr.read())
# ... loop de stream sobre stdout ...
rc = await proc.wait()
try:
stderr_b = await stderr_task
except Exception:
stderr_b = b""
stderr = stderr_b.decode("utf-8", errors="replace").strip()
La CLI de Claude Code emite poco stderr en modo stream-json, por lo que el modo de fallo es raro en la práctica. La solución es una línea extra.
Patrón 8: Disciplina en la edición de memoria
Un agente al estilo hermes lee sus propios logs de conversación como memoria. Los mensajes de fallback por bug escritos en ese log se vuelven indistinguibles del comportamiento pasado intencional en la siguiente lectura. El primer impulso es insertar marcadores de corrección ([CORRECTION: la entrada anterior fue un bug]) para que la siguiente lectura de memoria vea la corrección.
Verifica que el mensaje de fallback por bug fue realmente registrado antes de editar. En el caso anterior, el bloque genérico except Exception llamaba a message.answer(...) para enviar el error al usuario, pero no llamaba a conversation_log.log_outgoing(...). El mensaje de error llegó a Telegram pero nunca llegó al archivo de memoria del agente. No fue necesaria ninguna edición retroactiva.
Trata el workspace del agente como propiedad del agente. Antes de cualquier plan que implique editar archivos dentro de él, toma un snapshot fresco del estado —el agente puede haber reescrito su propio CLAUDE.md o notas desde la última lectura. La guía de ingeniería de contexto de Anthropic describe la memoria persistente como un artefacto entre sesiones, no como un bloc de notas en el que el operador garabatea. Las habilidades específicas de dominio son más duraderas cuando conviven con notas curadas por el agente en lugar de en archivos editados por el operador en los que el agente aprende a desconfiar.
Notas operacionales
Persistencia con bind-mount. Los volúmenes montados con bind para el workspace y las credenciales OAuth de Claude sobreviven a docker compose up -d --force-recreate siempre que las rutas de montaje no cambien. Verifica esto antes de cualquier edición al compose-file.
Verificación de seguridad antes del despliegue. Busca en los logs de los últimos cinco minutos un claude_subprocess_start sin un claude_result_event correspondiente. Un subprocess pendiente significa que un reinicio matará una ejecución en curso. Espera hasta que los logs estén limpios. Para escenarios de fallo más amplios, consulta nuestro artículo sobre recuperación ante desastres.
Reutilización de patrones entre mandatos. El stack completo —motor, handlers, log de conversación, ingesta de archivos— se clona a un nuevo mandato cambiando dos variables de entorno (un nombre de proyecto y un instance-id). El token del bot, las credenciales OAuth, el workspace y la allow-list se parametrizan por proyecto. Para el ángulo operacional de ejecutar muchos asistentes por proyecto en paralelo, consulta operaciones en solitario a escala.
Selección del emoji de reacción. El emoji 👀 está en el conjunto estándar por defecto de Telegram y funciona en grupos donde available_reactions no está definido. Si un grupo restringe a un subconjunto personalizado, la caché lo refleja y el mini-acuse de recibo se omite silenciosamente. Haz del emoji una constante de configuración por despliegue en lugar de un literal codificado.
Hermes-Agent versus una implementación mínima propia. El framework de NousResearch incluye TUI, sistema de slash-commands, gateway multicanal, hub de habilidades e integración de entrenamiento RL. Un wrapper mínimo de la CLI de Claude Code produce la misma forma conversacional con aproximadamente una décima parte de las piezas móviles. Ambos convergen en el mismo conjunto de problemas de UX en chat grupal; los patrones de este artículo aplican a cualquiera de los dos.
Cuándo aplicar cada patrón
Los patrones no son igualmente urgentes. Aplícalos en el orden en que los encuentres:
- El patrón 1 (manejo de respuesta vacía) es obligatorio en cuanto el bot se agrega a un grupo con detección de triggers por palabras clave.
- El patrón 4 (calendario de mensajes de espera) es obligatorio después de que la primera respuesta corta llega al mismo tiempo que el mensaje de confirmación.
- Los patrones 3 y 7 (modo stream, drenado de stderr) son obligatorios en cuanto las tareas de larga duración empiezan a colgarse.
- El patrón 5 (lock de concurrencia) es obligatorio cuando aparece la primera truncación del archivo de sesión en los logs.
- Los patrones 2, 6 y 8 son hardening de fondo —aplícalos durante la revisión de código antes de que fallen en producción.
Construye primero el asistente específico del proyecto: la guía de arquitectura base cubre el container, OAuth, workspace y layout de handlers. Incorpora un equipo pequeño con la guía de escalado para allow-lists, configuración de grupos y detección de triggers. Añade el canal de email para el proyecto con la guía de DKIM/DMARC cuando comiencen a llegar notificaciones fuera de banda. Vuelve a este artículo cuando necesites los patrones descritos aquí.
tva gestiona varios asistentes por proyecto en paralelo para diferentes mandatos de consultoría. Para recibir ayuda construyendo o ajustando el tuyo, contáctanos.
Artículos relacionados
- Construyendo un Asistente de IA Específico para Proyectos vía Telegram — la arquitectura base que esta guía ajusta
- Escalando un Asistente de IA de Telegram de Individual a Equipo — allow-lists, configuración de grupos, detección de triggers
- Configurando un Buzón para tu Agente de IA Específico del Proyecto — el canal de email para el mismo patrón por proyecto
- Construyendo Habilidades de Agente IA para Flujos de Trabajo Empresariales Específicos de Dominio — hacer al asistente útil para un dominio concreto
- Operaciones en Solitario a Escala: Gestionando Docenas de Proyectos con un Equipo Pequeño — operar muchos asistentes en paralelo